Files
git.stella-ops.org/docs/router/28-Step.md
master 75f6942769
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Add integration tests for migration categories and execution
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
2025-12-04 19:10:54 +02:00

21 KiB

Step 28: Agent Process Guidelines

Overview

This document provides comprehensive guidelines for AI agents (Claude, Copilot, etc.) implementing the Stella Router. It establishes conventions, patterns, and decision frameworks to ensure consistent, high-quality implementations across all phases.

Goals

  1. Define clear coding standards and patterns for Router implementation
  2. Establish decision frameworks for common scenarios
  3. Provide checklists for implementation quality
  4. Document testing requirements and coverage expectations
  5. Define commit and PR conventions

Implementation Standards

Code Organization

src/Router/
├── StellaOps.Router.Core/              # Core abstractions and contracts
│   ├── Abstractions/                   # Interfaces
│   ├── Configuration/                  # Config models
│   ├── Extensions/                     # Extension methods
│   └── Primitives/                     # Value types
│
├── StellaOps.Router.Gateway/           # Gateway implementation
│   ├── Routing/                        # Route matching
│   ├── Handlers/                       # Route handlers
│   ├── Pipeline/                       # Request pipeline
│   └── Middleware/                     # Gateway middleware
│
├── StellaOps.Router.Transport/         # Transport implementations
│   ├── InMemory/                       # In-process transport
│   ├── Tcp/                            # TCP transport
│   └── Tls/                            # TLS transport
│
├── StellaOps.Router.Microservice/      # Microservice SDK
│   ├── Hosting/                        # Host builder
│   ├── Endpoints/                      # Endpoint handling
│   └── Context/                        # Request context
│
├── StellaOps.Router.Security/          # Security components
│   ├── Jwt/                            # JWT validation
│   ├── Claims/                         # Claim hydration
│   └── RateLimiting/                   # Rate limiting
│
└── StellaOps.Router.Observability/     # Observability
    ├── Logging/                        # Structured logging
    ├── Metrics/                        # Prometheus metrics
    └── Tracing/                        # OpenTelemetry tracing

Naming Conventions

Element Convention Example
Interfaces I prefix, noun/adjective IRouteHandler, IConnectable
Classes PascalCase, noun JwtValidator, RouteTable
Async methods Async suffix ValidateTokenAsync, SendAsync
Config classes Options or Configuration suffix JwtValidationOptions
Event handlers On prefix OnConnectionEstablished
Factory methods Create prefix CreateHandler, CreateConnection
Boolean properties Is/Has/Can prefix IsValid, HasExpired, CanRetry

File Structure

// File: StellaOps.Router.Core/Abstractions/IRouteHandler.cs

// 1. License header (if required)
// 2. Using statements (sorted: System, Microsoft, Third-party, Internal)
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Router.Core.Configuration;

// 3. Namespace (one per file, matches folder structure)
namespace StellaOps.Router.Core.Abstractions;

// 4. XML documentation
/// <summary>
/// Handles requests for a specific route type.
/// </summary>
/// <remarks>
/// Implementations must be thread-safe and support concurrent request handling.
/// </remarks>
public interface IRouteHandler
{
    // 5. Interface members (properties, then methods)

    /// <summary>
    /// Gets the handler type identifier.
    /// </summary>
    string HandlerType { get; }

    /// <summary>
    /// Determines if this handler can process the given route.
    /// </summary>
    bool CanHandle(RouteConfiguration route);

    /// <summary>
    /// Processes an incoming request.
    /// </summary>
    Task<ResponsePayload> HandleAsync(
        RequestPayload request,
        RouteConfiguration route,
        CancellationToken cancellationToken = default);
}

Error Handling Patterns

// Pattern 1: Result types for expected failures
public readonly struct Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error == null;

    private Result(T? value, Error? error)
    {
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value, null);
    public static Result<T> Failure(Error error) => new(default, error);

    public Result<TNext> Map<TNext>(Func<T, TNext> map) =>
        IsSuccess ? Result<TNext>.Success(map(Value!)) : Result<TNext>.Failure(Error!);

    public async Task<Result<TNext>> MapAsync<TNext>(Func<T, Task<TNext>> map) =>
        IsSuccess ? Result<TNext>.Success(await map(Value!)) : Result<TNext>.Failure(Error!);
}

public record Error(string Code, string Message, Exception? Inner = null);

// Usage
public async Task<Result<JwtClaims>> ValidateTokenAsync(string token)
{
    try
    {
        var claims = await _validator.ValidateAsync(token);
        return Result<JwtClaims>.Success(claims);
    }
    catch (SecurityTokenExpiredException ex)
    {
        return Result<JwtClaims>.Failure(new Error("TOKEN_EXPIRED", "JWT has expired", ex));
    }
    catch (SecurityTokenInvalidSignatureException ex)
    {
        return Result<JwtClaims>.Failure(new Error("INVALID_SIGNATURE", "JWT signature invalid", ex));
    }
}

// Pattern 2: Exceptions for unexpected failures
public class RouterException : Exception
{
    public string ErrorCode { get; }
    public int StatusCode { get; }

    public RouterException(string errorCode, string message, int statusCode = 500)
        : base(message)
    {
        ErrorCode = errorCode;
        StatusCode = statusCode;
    }
}

public class ConfigurationException : RouterException
{
    public ConfigurationException(string message)
        : base("CONFIG_ERROR", message, 500) { }
}

public class TransportException : RouterException
{
    public TransportException(string message, Exception? inner = null)
        : base("TRANSPORT_ERROR", message, 503) { }
}

Async Patterns

// Pattern 1: CancellationToken propagation
public async Task<ResponsePayload> HandleAsync(
    RequestPayload request,
    CancellationToken cancellationToken = default)
{
    // Always check at start of long operations
    cancellationToken.ThrowIfCancellationRequested();

    // Propagate to all async calls
    var validated = await _validator.ValidateAsync(request, cancellationToken);
    var enriched = await _enricher.EnrichAsync(validated, cancellationToken);
    var response = await _handler.ProcessAsync(enriched, cancellationToken);

    return response;
}

// Pattern 2: Timeout handling
public async Task<T> WithTimeoutAsync<T>(
    Func<CancellationToken, Task<T>> operation,
    TimeSpan timeout,
    CancellationToken cancellationToken = default)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    cts.CancelAfter(timeout);

    try
    {
        return await operation(cts.Token);
    }
    catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
    {
        throw new TimeoutException($"Operation timed out after {timeout}");
    }
}

// Pattern 3: Fire-and-forget with logging
public void FireAndForget(Func<Task> operation, ILogger logger, string operationName)
{
    _ = Task.Run(async () =>
    {
        try
        {
            await operation();
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Fire-and-forget operation {Operation} failed", operationName);
        }
    });
}

Dependency Injection Patterns

// Pattern 1: Constructor injection with validation
public class JwtValidator : IJwtValidator
{
    private readonly JwtValidationOptions _options;
    private readonly IKeyProvider _keyProvider;
    private readonly ILogger<JwtValidator> _logger;

    public JwtValidator(
        IOptions<JwtValidationOptions> options,
        IKeyProvider keyProvider,
        ILogger<JwtValidator> logger)
    {
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));

        ValidateOptions(_options);
    }

    private static void ValidateOptions(JwtValidationOptions options)
    {
        if (string.IsNullOrEmpty(options.Issuer))
            throw new ConfigurationException("JWT issuer is required");
        if (options.ClockSkew < TimeSpan.Zero)
            throw new ConfigurationException("Clock skew cannot be negative");
    }
}

// Pattern 2: Factory registration for complex objects
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddStellaRouter(
        this IServiceCollection services,
        Action<RouterOptions> configure)
    {
        services.Configure(configure);

        // Core services
        services.AddSingleton<IRouteTable, RouteTable>();
        services.AddSingleton<IRequestPipeline, RequestPipeline>();

        // Keyed services for handlers
        services.AddKeyedSingleton<IRouteHandler, MicroserviceHandler>("microservice");
        services.AddKeyedSingleton<IRouteHandler, GraphQLHandler>("graphql");
        services.AddKeyedSingleton<IRouteHandler, ReverseProxyHandler>("proxy");

        // Factory for route handler resolution
        services.AddSingleton<IRouteHandlerFactory>(sp => new RouteHandlerFactory(
            sp.GetServices<IRouteHandler>().ToDictionary(h => h.HandlerType)));

        return services;
    }
}

// Pattern 3: Scoped services for request context
public static class RequestScopeExtensions
{
    public static IServiceCollection AddRequestScope(this IServiceCollection services)
    {
        services.AddScoped<IRequestContext, RequestContext>();
        services.AddScoped(sp => sp.GetRequiredService<IRequestContext>().User);
        services.AddScoped(sp => sp.GetRequiredService<IRequestContext>().CorrelationId);

        return services;
    }
}

Decision Framework

When to Create New Types vs. Reuse

Scenario Decision Rationale
Similar data, different context Create new type Type safety, clear intent
Same data, same context Reuse type DRY, reduce cognitive load
Third-party type Create wrapper Abstraction, testability
Config vs. runtime Separate types Immutability guarantees
// Example: Separate types for config vs runtime
public record RouteConfiguration(
    string Path,
    string Method,
    string HandlerType,
    Dictionary<string, string> Metadata);

public class CompiledRoute
{
    public RouteConfiguration Config { get; }
    public Regex PathPattern { get; }
    public IRouteHandler Handler { get; }
    // Runtime-computed fields
}

When to Use Interfaces vs. Abstract Classes

Use Interface Use Abstract Class
Multiple inheritance needed Shared implementation
Contract-only definition Template method pattern
Third-party implementation Internal hierarchy only
Mocking/testing priority Code reuse priority

Logging Level Guidelines

Level When to Use Example
Trace Internal flow details "Route matching attempt for {Path}"
Debug Diagnostic information "Cache hit for key {Key}"
Information Significant events "Request completed: {Method} {Path} → {Status}"
Warning Recoverable issues "Rate limit approaching: {Current}/{Max}"
Error Failures requiring attention "Failed to connect to Authority: {Error}"
Critical System-wide failures "Configuration invalid, router cannot start"
// Structured logging patterns
_logger.LogInformation(
    "Request processed: {Method} {Path} → {StatusCode} in {ElapsedMs}ms",
    request.Method,
    request.Path,
    response.StatusCode,
    stopwatch.ElapsedMilliseconds);

// Use LoggerMessage for high-performance paths
private static readonly Action<ILogger, string, string, int, long, Exception?> LogRequestComplete =
    LoggerMessage.Define<string, string, int, long>(
        LogLevel.Information,
        new EventId(1001, "RequestComplete"),
        "Request processed: {Method} {Path} → {StatusCode} in {ElapsedMs}ms");

// Usage
LogRequestComplete(_logger, method, path, statusCode, elapsed, null);

Implementation Checklists

Before Starting a Component

  • Read the step documentation thoroughly
  • Understand dependencies on previous steps
  • Review related existing code patterns
  • Identify configuration requirements
  • Plan test coverage strategy

During Implementation

  • Follow naming conventions
  • Add XML documentation to public APIs
  • Implement IDisposable/IAsyncDisposable where needed
  • Add structured logging at appropriate levels
  • Handle cancellation tokens throughout
  • Use result types for expected failures
  • Validate all configuration at startup

Before Marking Complete

  • All public types have XML documentation
  • Unit tests achieve >80% coverage
  • Integration tests cover happy path + error cases
  • No compiler warnings
  • Code passes all linting rules
  • Configuration is validated
  • README/documentation updated if needed

Pull Request Checklist

  • PR title follows convention: feat(router): description
  • Description explains what and why
  • All tests pass
  • No unrelated changes
  • Breaking changes documented
  • Reviewable size (<500 lines preferred)

Testing Requirements

Unit Test Coverage Targets

Component Type Target Coverage
Core logic 90%
Handlers 85%
Middleware 80%
Configuration 75%
Extensions 70%

Test Structure

// Test file naming: {ClassName}Tests.cs
// Test method naming: {Method}_{Scenario}_{ExpectedResult}

public class JwtValidatorTests
{
    private readonly JwtValidator _sut;  // System Under Test
    private readonly Mock<IKeyProvider> _keyProviderMock;
    private readonly Mock<ILogger<JwtValidator>> _loggerMock;

    public JwtValidatorTests()
    {
        _keyProviderMock = new Mock<IKeyProvider>();
        _loggerMock = new Mock<ILogger<JwtValidator>>();

        var options = Options.Create(new JwtValidationOptions
        {
            Issuer = "https://auth.example.com",
            Audience = "stella-router"
        });

        _sut = new JwtValidator(options, _keyProviderMock.Object, _loggerMock.Object);
    }

    [Fact]
    public async Task ValidateAsync_ValidToken_ReturnsSuccessWithClaims()
    {
        // Arrange
        var token = GenerateValidToken();
        _keyProviderMock
            .Setup(x => x.GetSigningKeyAsync(It.IsAny<string>()))
            .ReturnsAsync(TestKeys.ValidKey);

        // Act
        var result = await _sut.ValidateAsync(token);

        // Assert
        Assert.True(result.IsSuccess);
        Assert.NotNull(result.Value);
        Assert.Equal("test-user", result.Value.Subject);
    }

    [Fact]
    public async Task ValidateAsync_ExpiredToken_ReturnsFailure()
    {
        // Arrange
        var token = GenerateExpiredToken();

        // Act
        var result = await _sut.ValidateAsync(token);

        // Assert
        Assert.False(result.IsSuccess);
        Assert.Equal("TOKEN_EXPIRED", result.Error!.Code);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    public async Task ValidateAsync_NullOrEmptyToken_ReturnsFailure(string? token)
    {
        // Act
        var result = await _sut.ValidateAsync(token!);

        // Assert
        Assert.False(result.IsSuccess);
        Assert.Equal("INVALID_TOKEN", result.Error!.Code);
    }
}

Integration Test Patterns

public class RouterIntegrationTests : IClassFixture<RouterTestFixture>
{
    private readonly RouterTestFixture _fixture;

    public RouterIntegrationTests(RouterTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task EndToEnd_AuthenticatedRequest_ReturnsSuccess()
    {
        // Arrange
        var client = _fixture.CreateAuthenticatedClient(claims: new()
        {
            ["sub"] = "test-user",
            ["role"] = "admin"
        });

        // Act
        var response = await client.GetAsync("/api/users/123");

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);

        var user = await response.Content.ReadFromJsonAsync<UserDto>();
        Assert.NotNull(user);
        Assert.Equal("123", user.Id);
    }
}

// Test fixture
public class RouterTestFixture : IAsyncLifetime
{
    private IHost? _gatewayHost;
    private IHost? _microserviceHost;

    public async Task InitializeAsync()
    {
        // Start microservice
        _microserviceHost = await CreateMicroserviceHost();
        await _microserviceHost.StartAsync();

        // Start gateway
        _gatewayHost = await CreateGatewayHost();
        await _gatewayHost.StartAsync();
    }

    public async Task DisposeAsync()
    {
        if (_gatewayHost != null)
            await _gatewayHost.StopAsync();
        if (_microserviceHost != null)
            await _microserviceHost.StopAsync();

        _gatewayHost?.Dispose();
        _microserviceHost?.Dispose();
    }

    public HttpClient CreateAuthenticatedClient(Dictionary<string, object> claims)
    {
        var token = GenerateTestToken(claims);
        var client = new HttpClient
        {
            BaseAddress = new Uri("http://localhost:5000")
        };
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);
        return client;
    }
}

Git and PR Conventions

Branch Naming

feat/router-<step>-<description>
fix/router-<issue-number>
refactor/router-<description>
test/router-<description>
docs/router-<description>

Commit Messages

<type>(<scope>): <description>

[optional body]

[optional footer]

Types: feat, fix, refactor, test, docs, chore

Examples:

feat(router): implement JWT validation with per-endpoint keys

- Add JwtValidator with configurable key sources
- Support RS256 and ES256 algorithms
- Add JWKS endpoint caching with TTL

Closes #123

PR Template

## Summary
Brief description of what this PR does.

## Changes
- Change 1
- Change 2
- Change 3

## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] Manual testing performed

## Checklist
- [ ] Code follows project conventions
- [ ] Documentation updated
- [ ] No breaking changes (or documented if any)
- [ ] All tests pass

Common Pitfalls to Avoid

Performance

// ❌ BAD: Allocating in hot path
public bool MatchRoute(string path)
{
    var parts = path.Split('/');  // Allocation
    // ...
}

// ✅ GOOD: Use Span for parsing
public bool MatchRoute(ReadOnlySpan<char> path)
{
    // Zero-allocation parsing
    foreach (var segment in path.Split('/'))
    {
        // ...
    }
}

// ❌ BAD: Synchronous I/O blocking async context
public async Task ProcessAsync()
{
    var config = File.ReadAllText("config.json");  // Blocking!
}

// ✅ GOOD: Async all the way
public async Task ProcessAsync()
{
    var config = await File.ReadAllTextAsync("config.json");
}

Thread Safety

// ❌ BAD: Non-thread-safe collection
private readonly Dictionary<string, Route> _routes = new();

public void AddRoute(string key, Route route)
{
    _routes[key] = route;  // Not thread-safe!
}

// ✅ GOOD: Thread-safe collection
private readonly ConcurrentDictionary<string, Route> _routes = new();

public void AddRoute(string key, Route route)
{
    _routes[key] = route;  // Thread-safe
}

// ✅ GOOD: Immutable update
private ImmutableDictionary<string, Route> _routes =
    ImmutableDictionary<string, Route>.Empty;

public void AddRoute(string key, Route route)
{
    ImmutableInterlocked.AddOrUpdate(ref _routes, key, route, (_, _) => route);
}

Resource Management

// ❌ BAD: Not disposing resources
public async Task SendAsync(byte[] data)
{
    var client = new TcpClient();
    await client.ConnectAsync("host", 9100);
    await client.GetStream().WriteAsync(data);
    // client never disposed!
}

// ✅ GOOD: Proper disposal
public async Task SendAsync(byte[] data)
{
    using var client = new TcpClient();
    await client.ConnectAsync("host", 9100);
    await using var stream = client.GetStream();
    await stream.WriteAsync(data);
}

// ✅ GOOD: Connection pooling
public class ConnectionPool : IDisposable
{
    private readonly Channel<TcpClient> _pool;

    public async Task<TcpClient> RentAsync()
    {
        if (_pool.Reader.TryRead(out var client))
            return client;
        return await CreateNewConnectionAsync();
    }

    public void Return(TcpClient client)
    {
        if (!_pool.Writer.TryWrite(client))
            client.Dispose();
    }
}

Deliverables

Artifact Purpose
This document Agent implementation guidelines
Code templates Consistent starting points
Checklists Quality gates
Test patterns Consistent testing approach

Next Step

Step 29: Integration Testing & CI →