diff --git a/docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md b/docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md
new file mode 100644
index 0000000..8975add
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-15-pipeline-prerequisites.md
@@ -0,0 +1,990 @@
+# Pipeline Prerequisites Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add four small building blocks (`DexpaceClientOptions`, `DexpaceDiagnostics`, `UrlRedactor`, `PipelineContext`) to `Dexpace.Sdk.Core` so the pipeline and policy slices have their configuration/observability/context foundation.
+
+**Architecture:** Each new type lives in its own file under a new namespace folder (`Configuration/`, `Diagnostics/`, `Pipeline/`). All types are plain BCL — no new runtime packages needed for options or context; `System.Diagnostics.DiagnosticSource` (ActivitySource + Meter) ships in-box on net8/net10 BCL, so no NuGet package is required either. Four independent tasks, one commit each, driven by TDD.
+
+**Tech Stack:** C# `latest`, net8.0 / net10.0 multi-target, xUnit, `System.Diagnostics.ActivitySource`, `System.Diagnostics.Metrics.Meter`.
+
+---
+
+## Environment notes (critical)
+
+- Build: `dotnet build /Users/omar/dexpace/dotnet-sdk -c Release` — 0 warnings required (TreatWarningsAsErrors; CS1591 doc gate; EnforceCodeStyleInBuild).
+- Test: `dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj`
+ **Never** `dotnet test` bare — net8 runtime absent on this machine.
+- Every new `.cs` file must start with the two-line MIT header:
+ ```csharp
+ // Copyright (c) 2026 dexpace and Omar Aljarrah.
+ // Licensed under the MIT License. See LICENSE in the repository root for details.
+ ```
+- Every `public` member needs a `///` XML doc comment.
+- File-scoped namespaces (`namespace Foo;`), not block-scoped.
+- Central Package Management: version goes in `Directory.Packages.props`; `PackageReference` carries no `Version`.
+
+---
+
+## File map
+
+| New file | Responsibility |
+|---|---|
+| `src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs` | Root options POCO + `RetryOptions` + `RedirectOptions` |
+| `tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs` | Task A tests |
+| `src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs` | Static `ActivitySource` + `Meter` |
+| `tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs` | Task B tests |
+| `src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs` | Userinfo + sensitive-query-param redaction |
+| `tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs` | Task C tests |
+| `src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs` | Per-call mutable pipeline state |
+| `tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs` | Task D tests |
+
+---
+
+## Task A: DexpaceClientOptions POCOs
+
+**Files:**
+- Create: `src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs`
+- Create: `tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Configuration;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Configuration;
+
+public class DexpaceClientOptionsTests
+{
+ [Fact]
+ public void DexpaceClientOptions_Defaults_AreCorrect()
+ {
+ var opts = new DexpaceClientOptions();
+
+ Assert.Null(opts.BaseAddress);
+ Assert.NotNull(opts.UserAgent);
+ Assert.StartsWith("dexpace-dotnet/", opts.UserAgent);
+ Assert.Null(opts.OverallTimeout);
+ Assert.Null(opts.AttemptTimeout);
+ Assert.NotNull(opts.Retry);
+ Assert.NotNull(opts.Redirect);
+ }
+
+ [Fact]
+ public void RetryOptions_Defaults_AreCorrect()
+ {
+ var retry = new RetryOptions();
+
+ Assert.Equal(3, retry.MaxRetryAttempts);
+ Assert.Equal(TimeSpan.FromMilliseconds(200), retry.BaseDelay);
+ Assert.Equal(TimeSpan.FromSeconds(30), retry.MaxDelay);
+ Assert.True(retry.HonorRetryAfter);
+ Assert.False(retry.RetryNonIdempotentWhenReplayable);
+ }
+
+ [Fact]
+ public void RedirectOptions_Defaults_AreCorrect()
+ {
+ var redirect = new RedirectOptions();
+
+ Assert.Equal(20, redirect.MaxRedirects);
+ Assert.False(redirect.AllowHttpsToHttpDowngrade);
+ Assert.True(redirect.StripSensitiveHeadersOnCrossOrigin);
+ }
+
+ [Fact]
+ public void DexpaceClientOptions_RetryAndRedirect_AreNonNullOnFreshInstance()
+ {
+ var opts = new DexpaceClientOptions();
+
+ // Property bag objects must be initialized — not null — so callers can do
+ // opts.Retry.MaxRetryAttempts = 5 without a null ref.
+ Assert.NotNull(opts.Retry);
+ Assert.NotNull(opts.Redirect);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: build error — `Dexpace.Sdk.Core.Configuration` namespace not found.
+
+- [ ] **Step 3: Create the implementation**
+
+Create `src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Reflection;
+
+namespace Dexpace.Sdk.Core.Configuration;
+
+///
+/// Top-level configuration options for the Dexpace SDK client.
+///
+///
+/// All properties carry sensible defaults; the client is fully usable with new DexpaceClientOptions().
+/// Per-policy sub-options are exposed as nested objects (, ).
+///
+public sealed class DexpaceClientOptions
+{
+ private static readonly string s_defaultUserAgent = BuildDefaultUserAgent();
+
+ ///
+ /// The base address prepended to relative request URLs, or when
+ /// requests always use absolute URLs.
+ ///
+ public Uri? BaseAddress { get; set; }
+
+ ///
+ /// The User-Agent header value sent with every request.
+ /// Defaults to dexpace-dotnet/<assembly-version>.
+ ///
+ public string UserAgent { get; set; } = s_defaultUserAgent;
+
+ ///
+ /// The wall-clock deadline for an entire operation (all redirect hops and retry attempts
+ /// combined), or for no overall deadline.
+ ///
+ public TimeSpan? OverallTimeout { get; set; }
+
+ ///
+ /// The deadline for a single send attempt, or for no per-attempt deadline.
+ ///
+ public TimeSpan? AttemptTimeout { get; set; }
+
+ ///
+ /// Retry-policy options. Defaults to with its built-in defaults.
+ ///
+ public RetryOptions Retry { get; set; } = new();
+
+ ///
+ /// Redirect-policy options. Defaults to with its built-in defaults.
+ ///
+ public RedirectOptions Redirect { get; set; } = new();
+
+ private static string BuildDefaultUserAgent()
+ {
+ var version = typeof(DexpaceClientOptions).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion
+ ?? typeof(DexpaceClientOptions).Assembly.GetName().Version?.ToString()
+ ?? "0.0.0";
+
+ // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1").
+ var plusIndex = version.IndexOf('+', StringComparison.Ordinal);
+ if (plusIndex >= 0)
+ {
+ version = version[..plusIndex];
+ }
+
+ return $"dexpace-dotnet/{version}";
+ }
+}
+
+///
+/// Options for the retry policy.
+///
+public sealed class RetryOptions
+{
+ ///
+ /// The number of retry attempts after the initial send. Defaults to 3.
+ /// Matches the Polly v8 / Microsoft.Extensions.Http.Resilience naming convention.
+ ///
+ public int MaxRetryAttempts { get; set; } = 3;
+
+ ///
+ /// The base delay for exponential back-off. Defaults to 200 ms.
+ ///
+ public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(200);
+
+ ///
+ /// The maximum back-off delay cap. Defaults to 30 s.
+ ///
+ public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// When , the retry policy respects a Retry-After response
+ /// header. Defaults to .
+ ///
+ public bool HonorRetryAfter { get; set; } = true;
+
+ ///
+ /// When , the retry policy may retry non-idempotent methods (e.g.
+ /// POST) if the request body is replayable. Defaults to .
+ ///
+ public bool RetryNonIdempotentWhenReplayable { get; set; }
+}
+
+///
+/// Options for the redirect-following policy.
+///
+public sealed class RedirectOptions
+{
+ ///
+ /// The maximum number of redirect hops to follow. Defaults to 20,
+ /// matching browser and HttpClient norms.
+ ///
+ public int MaxRedirects { get; set; } = 20;
+
+ ///
+ /// When , the policy follows https → http downgrade redirects.
+ /// Defaults to for security.
+ ///
+ public bool AllowHttpsToHttpDowngrade { get; set; }
+
+ ///
+ /// When , sensitive headers (e.g. Authorization) are stripped
+ /// when the redirect crosses an origin boundary. Defaults to .
+ ///
+ public bool StripSensitiveHeadersOnCrossOrigin { get; set; } = true;
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: all prior tests still pass, plus 4 new ones — total 34 passed, 0 failed.
+
+- [ ] **Step 5: Verify full build is clean (0 warnings)**
+
+```bash
+dotnet build /Users/omar/dexpace/dotnet-sdk -c Release
+```
+
+Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git -C /Users/omar/dexpace/dotnet-sdk add \
+ src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs \
+ tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs
+git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add DexpaceClientOptions and per-policy option types"
+```
+
+---
+
+## Task B: Diagnostics ActivitySource and Meter
+
+**Files:**
+- Create: `src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs`
+- Create: `tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs`
+
+**Note on package:** `ActivitySource` lives in `System.Diagnostics` and `Meter` in `System.Diagnostics.Metrics`; both ship in the BCL on net8+ via `System.Diagnostics.DiagnosticSource`. Try building first — if the types are missing, add `System.Diagnostics.DiagnosticSource` to `Directory.Packages.props` and a `PackageReference` (no `Version`) in the Core `.csproj`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Diagnostics;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Diagnostics;
+
+public class DexpaceDiagnosticsTests
+{
+ [Fact]
+ public void ActivitySource_HasCorrectName()
+ {
+ Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.ActivitySource.Name);
+ }
+
+ [Fact]
+ public void Meter_HasCorrectName()
+ {
+ Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.Meter.Name);
+ }
+
+ [Fact]
+ public void ActivitySource_VersionIsNonEmpty()
+ {
+ Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.ActivitySource.Version));
+ }
+
+ [Fact]
+ public void Meter_VersionIsNonEmpty()
+ {
+ Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.Meter.Version));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: build error — `DexpaceDiagnostics` not found.
+
+- [ ] **Step 3: Create the implementation**
+
+Create `src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Reflection;
+
+namespace Dexpace.Sdk.Core.Diagnostics;
+
+///
+/// Central home for the SDK's OpenTelemetry instrumentation objects.
+///
+///
+/// Consumers wire up collection by subscribing to (tracing) and
+/// (metrics) via their chosen OTel SDK — no SDK-internal configuration needed.
+/// When no listener is active, returns
+/// and the hot path allocates nothing for tracing.
+///
+public static class DexpaceDiagnostics
+{
+ private static readonly string s_version = BuildVersion();
+
+ ///
+ /// The used by the SDK for distributed tracing.
+ /// Name: "Dexpace.Sdk".
+ ///
+ public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", s_version);
+
+ ///
+ /// The used by the SDK for metrics.
+ /// Name: "Dexpace.Sdk".
+ ///
+ public static readonly Meter Meter = new("Dexpace.Sdk", s_version);
+
+ private static string BuildVersion()
+ {
+ var version = typeof(DexpaceDiagnostics).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion
+ ?? typeof(DexpaceDiagnostics).Assembly.GetName().Version?.ToString()
+ ?? "0.0.0";
+
+ // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1").
+ var plusIndex = version.IndexOf('+', StringComparison.Ordinal);
+ return plusIndex >= 0 ? version[..plusIndex] : version;
+ }
+}
+```
+
+- [ ] **Step 4: Attempt build — check if BCL types are available**
+
+```bash
+dotnet build /Users/omar/dexpace/dotnet-sdk -c Release
+```
+
+If build succeeds with 0 warnings, proceed to Step 5.
+
+If build fails with `CS0234`/`CS0246` errors about `ActivitySource` or `Meter` not found:
+1. Add to `Directory.Packages.props` inside the existing ``:
+ ```xml
+
+ ```
+2. Add to `src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj` inside an ``:
+ ```xml
+
+ ```
+3. Run `dotnet restore /Users/omar/dexpace/dotnet-sdk` then re-run the build.
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: 4 new tests pass, total 38 (or 34 + 4 if Task A was committed), 0 failed.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git -C /Users/omar/dexpace/dotnet-sdk add \
+ src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs \
+ tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs
+# Include package changes if they were needed:
+# git -C /Users/omar/dexpace/dotnet-sdk add Directory.Packages.props src/Dexpace.Sdk.Core/Dexpace.Sdk.Core.csproj
+git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add Dexpace diagnostics ActivitySource and Meter"
+```
+
+---
+
+## Task C: UrlRedactor
+
+**Files:**
+- Create: `src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs`
+- Create: `tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Diagnostics;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Diagnostics;
+
+public class UrlRedactorTests
+{
+ // Use the default-set instance for most tests.
+ private static readonly UrlRedactor DefaultRedactor = new();
+
+ [Fact]
+ public void Redact_UserInfo_IsStripped()
+ {
+ var uri = new Uri("https://user:secret@api.example.com/path");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.DoesNotContain("user", result);
+ Assert.DoesNotContain("secret", result);
+ Assert.Contains("api.example.com", result);
+ }
+
+ [Fact]
+ public void Redact_SensitiveQueryParam_ValueIsReplaced()
+ {
+ var uri = new Uri("https://api.example.com/v1/items?access_token=super-secret&page=2");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("access_token=REDACTED", result);
+ Assert.Contains("page=2", result);
+ Assert.DoesNotContain("super-secret", result);
+ }
+
+ [Fact]
+ public void Redact_NonSensitiveQueryParam_IsPreserved()
+ {
+ var uri = new Uri("https://api.example.com/search?q=hello&lang=en");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("q=hello", result);
+ Assert.Contains("lang=en", result);
+ }
+
+ [Fact]
+ public void Redact_SensitiveParamCheck_IsCaseInsensitive()
+ {
+ var uri = new Uri("https://api.example.com/v1?API_KEY=abc123");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("API_KEY=REDACTED", result);
+ Assert.DoesNotContain("abc123", result);
+ }
+
+ [Fact]
+ public void Redact_NoQueryString_ReturnsSafeUrl()
+ {
+ var uri = new Uri("https://api.example.com/v1/resource");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Equal("https://api.example.com/v1/resource", result);
+ }
+
+ [Fact]
+ public void Redact_CustomSensitiveParams_AreRedacted()
+ {
+ var redactor = new UrlRedactor(["x-custom-secret"]);
+ var uri = new Uri("https://api.example.com/?x-custom-secret=mysecret&other=value");
+ var result = redactor.Redact(uri);
+
+ Assert.Contains("x-custom-secret=REDACTED", result);
+ Assert.Contains("other=value", result);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: build error — `UrlRedactor` not found.
+
+- [ ] **Step 3: Create the implementation**
+
+Create `src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Text;
+using System.Web;
+
+namespace Dexpace.Sdk.Core.Diagnostics;
+
+///
+/// Produces a log-safe string form of a by stripping userinfo and
+/// replacing the values of known-sensitive query parameters with REDACTED.
+///
+///
+/// Sensitive parameter names are matched case-insensitively. The default set covers the most
+/// common credential-bearing parameters; callers may supply a custom set instead.
+/// Non-sensitive parameters and all path/host/scheme components are preserved verbatim.
+///
+public sealed class UrlRedactor
+{
+ ///
+ /// Default set of query parameter names whose values are redacted.
+ ///
+ public static readonly IReadOnlyCollection DefaultSensitiveParams =
+ [
+ "access_token",
+ "token",
+ "code",
+ "sig",
+ "signature",
+ "api_key",
+ "apikey",
+ "password",
+ ];
+
+ private readonly HashSet _sensitiveParams;
+
+ ///
+ /// Initializes a using .
+ ///
+ public UrlRedactor()
+ : this(DefaultSensitiveParams)
+ {
+ }
+
+ ///
+ /// Initializes a with a caller-supplied set of sensitive
+ /// parameter names (case-insensitive).
+ ///
+ ///
+ /// The query parameter names whose values should be replaced with REDACTED.
+ ///
+ public UrlRedactor(IEnumerable sensitiveParams)
+ {
+ ArgumentNullException.ThrowIfNull(sensitiveParams);
+ _sensitiveParams = new HashSet(sensitiveParams, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Returns a log-safe representation of : userinfo is always stripped;
+ /// sensitive query parameter values are replaced with REDACTED.
+ ///
+ /// The URI to redact.
+ /// A safe string representation.
+ public string Redact(Uri uri)
+ {
+ ArgumentNullException.ThrowIfNull(uri);
+
+ var query = uri.Query;
+
+ // Build the base URL without userinfo and without the query string.
+ var builder = new UriBuilder(uri)
+ {
+ UserName = string.Empty,
+ Password = string.Empty,
+ Query = string.Empty,
+ };
+ var baseUrl = builder.Uri.GetLeftPart(UriPartial.Path);
+
+ if (string.IsNullOrEmpty(query))
+ {
+ return baseUrl;
+ }
+
+ // Parse, redact, and re-serialize the query string.
+ var parsed = HttpUtility.ParseQueryString(query);
+ var sb = new StringBuilder();
+
+ foreach (string? key in parsed)
+ {
+ if (key is null)
+ {
+ continue;
+ }
+
+ if (sb.Length > 0)
+ {
+ sb.Append('&');
+ }
+
+ var value = _sensitiveParams.Contains(key) ? "REDACTED" : parsed[key];
+ sb.Append(Uri.EscapeDataString(key));
+ sb.Append('=');
+ sb.Append(value is null ? string.Empty : Uri.EscapeDataString(value));
+ }
+
+ return sb.Length == 0 ? baseUrl : $"{baseUrl}?{sb}";
+ }
+}
+```
+
+- [ ] **Step 4: Attempt build — `System.Web` availability check**
+
+`HttpUtility.ParseQueryString` lives in `System.Web`. On net8+ it is in-box. Build to verify:
+
+```bash
+dotnet build /Users/omar/dexpace/dotnet-sdk -c Release
+```
+
+If build fails with `CS0234` on `System.Web`, replace the `HttpUtility.ParseQueryString` approach with the manual query-string parser below (drop `using System.Web;`, use this helper instead):
+
+```csharp
+// Drop-in replacement for HttpUtility.ParseQueryString — no System.Web needed.
+private static IEnumerable<(string Key, string Value)> ParseQueryParams(string query)
+{
+ var span = query.AsSpan().TrimStart('?');
+ foreach (var segment in span.Split('&'))
+ {
+ var part = span[segment];
+ var eq = part.IndexOf('=');
+ if (eq < 0) continue;
+ yield return (
+ Uri.UnescapeDataString(part[..eq].ToString()),
+ Uri.UnescapeDataString(part[(eq + 1)..].ToString()));
+ }
+}
+```
+
+And replace the `ParseQueryString`/`foreach` block in `Redact` with:
+
+```csharp
+var sb = new StringBuilder();
+foreach (var (key, value) in ParseQueryParams(query))
+{
+ if (sb.Length > 0) sb.Append('&');
+ var redactedValue = _sensitiveParams.Contains(key) ? "REDACTED" : value;
+ sb.Append(Uri.EscapeDataString(key));
+ sb.Append('=');
+ sb.Append(Uri.EscapeDataString(redactedValue));
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: 6 new tests pass, 0 failed, total grows by 6.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git -C /Users/omar/dexpace/dotnet-sdk add \
+ src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs \
+ tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs
+git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add UrlRedactor for log-safe URLs"
+```
+
+---
+
+## Task D: PipelineContext
+
+**Files:**
+- Create: `src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs`
+- Create: `tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Http.Common;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Pipeline;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Pipeline;
+
+public class PipelineContextTests
+{
+ private static Request MakeRequest() =>
+ Request.Get("https://api.example.com/v1/resource");
+
+ [Fact]
+ public void Constructor_StoresRequest()
+ {
+ var request = MakeRequest();
+ var options = new DexpaceClientOptions();
+ var ctx = new PipelineContext(request, options);
+
+ Assert.Same(request, ctx.Request);
+ }
+
+ [Fact]
+ public void Constructor_StoresOptions()
+ {
+ var options = new DexpaceClientOptions { UserAgent = "test-agent/1.0" };
+ var ctx = new PipelineContext(MakeRequest(), options);
+
+ Assert.Same(options, ctx.Options);
+ }
+
+ [Fact]
+ public void Constructor_StoresCancellationToken()
+ {
+ using var cts = new CancellationTokenSource();
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions(), cts.Token);
+
+ Assert.Equal(cts.Token, ctx.CancellationToken);
+ }
+
+ [Fact]
+ public void Constructor_DefaultCancellationToken_IsDefault()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Equal(CancellationToken.None, ctx.CancellationToken);
+ }
+
+ [Fact]
+ public void AttemptNumber_DefaultsToZero()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Equal(0, ctx.AttemptNumber);
+ }
+
+ [Fact]
+ public void Response_DefaultsToNull()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Null(ctx.Response);
+ }
+
+ [Fact]
+ public void Activity_DefaultsToNull()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Null(ctx.Activity);
+ }
+
+ [Fact]
+ public void PropertyBag_RoundTrips_TypedValue()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+ ctx.SetProperty("idempotency-key", "idem-abc123");
+ var retrieved = ctx.GetProperty("idempotency-key");
+
+ Assert.Equal("idem-abc123", retrieved);
+ }
+
+ [Fact]
+ public void PropertyBag_MissingKey_ReturnsDefault()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+ var result = ctx.GetProperty("nonexistent");
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public void PropertyBag_MissingKeyForReferenceType_ReturnsNull()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+ var result = ctx.GetProperty("nonexistent");
+
+ Assert.Null(result);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: build error — `PipelineContext` not found.
+
+- [ ] **Step 3: Create the implementation**
+
+Create `src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs`:
+
+```csharp
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Diagnostics;
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Http.Response;
+
+namespace Dexpace.Sdk.Core.Pipeline;
+
+///
+/// Carries all per-call mutable state as it flows through the pipeline delegate chain.
+///
+///
+/// One instance is created per client call and passed to every policy in the chain. Policies
+/// read and replace (for redirect/auth rewriting), stash cross-policy
+/// coordination data in the property bag (see /
+/// ), and observe once the transport has
+/// responded.
+///
+public sealed class PipelineContext
+{
+ private Dictionary? _properties;
+
+ ///
+ /// Initializes a new for a single client call.
+ ///
+ /// The initial request. Policies may replace it during the call.
+ /// A snapshot of the client options for this call.
+ ///
+ /// An optional cancellation token that can abort the call.
+ ///
+ public PipelineContext(
+ Request request,
+ DexpaceClientOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(options);
+
+ Request = request;
+ Options = options;
+ CancellationToken = cancellationToken;
+ }
+
+ ///
+ /// The current request. Policies (e.g. redirect, auth) may replace this during the call.
+ ///
+ public Request Request { get; set; }
+
+ ///
+ /// The response from the transport, or before the transport responds.
+ /// Set by the pipeline after the outermost transport send completes.
+ ///
+ public Response? Response { get; internal set; }
+
+ ///
+ /// The active SDK tracing span, or when no
+ /// listener is registered (near-zero overhead when unobserved).
+ ///
+ public Activity? Activity { get; internal set; }
+
+ ///
+ /// A snapshot of the client options that applies to this call.
+ ///
+ public DexpaceClientOptions Options { get; }
+
+ ///
+ /// A token that can cancel the in-flight operation.
+ ///
+ public CancellationToken CancellationToken { get; }
+
+ ///
+ /// The zero-based retry attempt counter. 0 on the initial send;
+ /// incremented by the retry policy before each subsequent attempt.
+ ///
+ public int AttemptNumber { get; internal set; }
+
+ ///
+ /// Retrieves a typed value from the cross-policy property bag.
+ ///
+ /// The expected type of the stored value.
+ /// The key used when the value was stored.
+ ///
+ /// The stored value cast to , or
+ /// if the key is absent or the stored value cannot be cast.
+ ///
+ public T? GetProperty(string key)
+ {
+ if (_properties is null || !_properties.TryGetValue(key, out var value))
+ {
+ return default;
+ }
+
+ return value is T typed ? typed : default;
+ }
+
+ ///
+ /// Stores a typed value in the cross-policy property bag.
+ /// Overwrites any existing value for .
+ ///
+ /// The type of the value to store.
+ /// A key that identifies the value within this call.
+ /// The value to store.
+ public void SetProperty(string key, T value)
+ {
+ _properties ??= new Dictionary(StringComparer.Ordinal);
+ _properties[key] = value;
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: 10 new tests pass, 0 failed.
+
+- [ ] **Step 5: Final build verification (0 warnings)**
+
+```bash
+dotnet build /Users/omar/dexpace/dotnet-sdk -c Release
+```
+
+Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git -C /Users/omar/dexpace/dotnet-sdk add \
+ src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs \
+ tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs
+git -C /Users/omar/dexpace/dotnet-sdk commit -m "feat: add PipelineContext for per-call pipeline state"
+```
+
+---
+
+## Final verification
+
+- [ ] **Run full build one last time**
+
+```bash
+dotnet build /Users/omar/dexpace/dotnet-sdk -c Release
+```
+
+Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`.
+
+- [ ] **Run the full Core test suite on net10.0**
+
+```bash
+dotnet test -c Release -f net10.0 /Users/omar/dexpace/dotnet-sdk/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj
+```
+
+Expected: all prior tests pass, plus ~24 new ones (4+4+6+10). 0 failures.
diff --git a/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs b/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs
new file mode 100644
index 0000000..1e9a977
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Configuration/DexpaceClientOptions.cs
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Internal;
+
+namespace Dexpace.Sdk.Core.Configuration;
+
+///
+/// Top-level configuration options for the Dexpace SDK client.
+///
+///
+/// All properties carry sensible defaults; the client is fully usable with new DexpaceClientOptions().
+/// Per-policy sub-options are exposed as nested objects (, ).
+///
+public sealed class DexpaceClientOptions
+{
+ private static readonly string s_defaultUserAgent = BuildDefaultUserAgent();
+
+ ///
+ /// The base address prepended to relative request URLs, or when
+ /// requests always use absolute URLs.
+ ///
+ public Uri? BaseAddress { get; set; }
+
+ ///
+ /// The User-Agent header value sent with every request.
+ /// Defaults to dexpace-dotnet/<assembly-version>.
+ ///
+ public string UserAgent { get; set; } = s_defaultUserAgent;
+
+ ///
+ /// The wall-clock deadline for an entire operation (all redirect hops and retry attempts
+ /// combined), or for no overall deadline.
+ ///
+ public TimeSpan? OverallTimeout { get; set; }
+
+ ///
+ /// The deadline for a single send attempt, or for no per-attempt deadline.
+ ///
+ public TimeSpan? AttemptTimeout { get; set; }
+
+ ///
+ /// Retry-policy options. Defaults to with its built-in defaults.
+ ///
+ public RetryOptions Retry { get; set; } = new();
+
+ ///
+ /// Redirect-policy options. Defaults to with its built-in defaults.
+ ///
+ public RedirectOptions Redirect { get; set; } = new();
+
+ private static string BuildDefaultUserAgent() =>
+ $"dexpace-dotnet/{SdkVersion.Value}";
+}
+
+///
+/// Options for the retry policy.
+///
+public sealed class RetryOptions
+{
+ ///
+ /// The number of retry attempts after the initial send. Defaults to 3.
+ /// Matches the Polly v8 / Microsoft.Extensions.Http.Resilience naming convention.
+ ///
+ public int MaxRetryAttempts { get; set; } = 3;
+
+ ///
+ /// The base delay for exponential back-off. Defaults to 200 ms.
+ ///
+ public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(200);
+
+ ///
+ /// The maximum back-off delay cap. Defaults to 30 s.
+ ///
+ public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// When , the retry policy respects a Retry-After response
+ /// header. Defaults to .
+ ///
+ public bool HonorRetryAfter { get; set; } = true;
+
+ ///
+ /// When , the retry policy may retry non-idempotent methods (e.g.
+ /// POST) if the request body is replayable. Defaults to .
+ ///
+ public bool RetryNonIdempotentWhenReplayable { get; set; }
+}
+
+///
+/// Options for the redirect-following policy.
+///
+public sealed class RedirectOptions
+{
+ ///
+ /// The maximum number of redirect hops to follow. Defaults to 20,
+ /// matching browser and HttpClient norms.
+ ///
+ public int MaxRedirects { get; set; } = 20;
+
+ ///
+ /// When , the policy follows https → http downgrade redirects.
+ /// Defaults to for security.
+ ///
+ public bool AllowHttpsToHttpDowngrade { get; set; }
+
+ ///
+ /// When , sensitive headers (e.g. Authorization) are stripped
+ /// when the redirect crosses an origin boundary. Defaults to .
+ ///
+ public bool StripSensitiveHeadersOnCrossOrigin { get; set; } = true;
+}
diff --git a/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs
new file mode 100644
index 0000000..426416e
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceDiagnostics.cs
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using Dexpace.Sdk.Core.Internal;
+
+namespace Dexpace.Sdk.Core.Diagnostics;
+
+///
+/// Central home for the SDK's OpenTelemetry instrumentation objects.
+///
+///
+/// Consumers wire up collection by subscribing to (tracing) and
+/// (metrics) via their chosen OTel SDK — no SDK-internal configuration needed.
+/// When no listener is active, ActivitySource.StartActivity returns
+/// and the hot path allocates nothing for tracing.
+///
+public static class DexpaceDiagnostics
+{
+ ///
+ /// The used by the SDK for distributed tracing.
+ /// Name: "Dexpace.Sdk".
+ ///
+ public static readonly ActivitySource ActivitySource = new("Dexpace.Sdk", SdkVersion.Value);
+
+ ///
+ /// The used by the SDK for metrics.
+ /// Name: "Dexpace.Sdk".
+ ///
+ public static readonly Meter Meter = new("Dexpace.Sdk", SdkVersion.Value);
+}
diff --git a/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs b/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs
new file mode 100644
index 0000000..a67edd5
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Diagnostics/UrlRedactor.cs
@@ -0,0 +1,189 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Text;
+
+namespace Dexpace.Sdk.Core.Diagnostics;
+
+///
+/// Produces a log-safe string form of a by stripping userinfo and
+/// replacing the values of known-sensitive query parameters with REDACTED.
+///
+///
+///
+/// Sensitive parameter names are matched case-insensitively. The default set covers the most
+/// common credential-bearing parameters; callers may supply a custom set instead.
+///
+///
+/// Redaction boundary:
+///
+/// -
+/// Userinfo (the user:password@ segment of an authority) is always
+/// removed.
+///
+/// -
+/// Sensitive query-parameter values are replaced with REDACTED;
+/// names and non-sensitive parameters are preserved verbatim.
+///
+/// -
+/// Fragment (the #… portion) is always dropped — fragments are
+/// client-side only and carry no information relevant to logging.
+///
+/// -
+/// Path segments are preserved verbatim. Callers must not embed secrets
+/// inside the URL path; this class does not inspect or redact path components.
+///
+///
+///
+///
+public sealed class UrlRedactor
+{
+ ///
+ /// Default set of query parameter names whose values are redacted.
+ ///
+ public static readonly IReadOnlyCollection DefaultSensitiveParams =
+ [
+ "access_token",
+ "token",
+ "code",
+ "sig",
+ "signature",
+ "api_key",
+ "apikey",
+ "password",
+ ];
+
+ private readonly HashSet _sensitiveParams;
+
+ ///
+ /// Initializes a using .
+ ///
+ public UrlRedactor()
+ : this(DefaultSensitiveParams)
+ {
+ }
+
+ ///
+ /// Initializes a with a caller-supplied set of sensitive
+ /// parameter names (case-insensitive).
+ ///
+ ///
+ /// The query parameter names whose values should be replaced with REDACTED.
+ ///
+ public UrlRedactor(IEnumerable sensitiveParams)
+ {
+ ArgumentNullException.ThrowIfNull(sensitiveParams);
+ _sensitiveParams = new HashSet(sensitiveParams, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Returns a log-safe representation of : userinfo is always stripped;
+ /// sensitive query parameter values are replaced with REDACTED; the fragment is
+ /// dropped. For non-absolute URIs the method operates on
+ /// and never throws.
+ ///
+ /// The URI to redact. May be relative or absolute.
+ /// A safe string representation.
+ public string Redact(Uri uri)
+ {
+ ArgumentNullException.ThrowIfNull(uri);
+
+ if (uri.IsAbsoluteUri)
+ {
+ return RedactAbsolute(uri);
+ }
+
+ return RedactRelative(uri.OriginalString);
+ }
+
+ // Handles fully-qualified URIs where Uri properties are safe to access.
+ private string RedactAbsolute(Uri uri)
+ {
+ var query = uri.Query;
+
+ // Build the base URL without userinfo and without the query string.
+ var builder = new UriBuilder(uri)
+ {
+ UserName = string.Empty,
+ Password = string.Empty,
+ Query = string.Empty,
+ Fragment = string.Empty,
+ };
+ var baseUrl = builder.Uri.GetLeftPart(UriPartial.Path);
+
+ if (string.IsNullOrEmpty(query))
+ {
+ return baseUrl;
+ }
+
+ var redactedQuery = RedactQuery(query);
+ return redactedQuery.Length == 0 ? baseUrl : $"{baseUrl}?{redactedQuery}";
+ }
+
+ // Handles relative URI references by operating on the raw string directly.
+ // Relative references have no userinfo, so only fragment dropping and query redaction apply.
+ private string RedactRelative(string originalString)
+ {
+ // Drop the fragment first (everything from the first '#').
+ var fragmentIndex = originalString.IndexOf('#', StringComparison.Ordinal);
+ var withoutFragment = fragmentIndex >= 0
+ ? originalString[..fragmentIndex]
+ : originalString;
+
+ // Split path from query on the first '?'.
+ var queryIndex = withoutFragment.IndexOf('?', StringComparison.Ordinal);
+ if (queryIndex < 0)
+ {
+ // No query string — return path as-is (fragment already dropped).
+ return withoutFragment;
+ }
+
+ var path = withoutFragment[..queryIndex];
+ var query = withoutFragment[queryIndex..]; // includes the leading '?'
+
+ var redactedQuery = RedactQuery(query);
+ return redactedQuery.Length == 0 ? path : $"{path}?{redactedQuery}";
+ }
+
+ // Redacts sensitive parameter values in a raw query string (with or without a leading '?').
+ // Returns the redacted query string without the leading '?', or an empty string if there are
+ // no key=value pairs after redaction.
+ private string RedactQuery(string query)
+ {
+ var sb = new StringBuilder();
+ foreach (var (key, value) in ParseQueryParams(query))
+ {
+ if (sb.Length > 0)
+ {
+ sb.Append('&');
+ }
+
+ var redactedValue = _sensitiveParams.Contains(key) ? "REDACTED" : value;
+ sb.Append(Uri.EscapeDataString(key));
+ sb.Append('=');
+ sb.Append(Uri.EscapeDataString(redactedValue));
+ }
+
+ return sb.ToString();
+ }
+
+ private static IEnumerable<(string Key, string Value)> ParseQueryParams(string query)
+ {
+ var raw = query.TrimStart('?');
+ foreach (var part in raw.Split('&'))
+ {
+ var eq = part.IndexOf('=', StringComparison.Ordinal);
+
+ // Valueless params (e.g. "?flag") are intentionally skipped — a bare key
+ // carries no value that could leak a secret.
+ if (eq < 0)
+ {
+ continue;
+ }
+
+ yield return (
+ Uri.UnescapeDataString(part[..eq]),
+ Uri.UnescapeDataString(part[(eq + 1)..]));
+ }
+ }
+}
diff --git a/src/Dexpace.Sdk.Core/Internal/SdkVersion.cs b/src/Dexpace.Sdk.Core/Internal/SdkVersion.cs
new file mode 100644
index 0000000..361617f
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Internal/SdkVersion.cs
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Reflection;
+
+namespace Dexpace.Sdk.Core.Internal;
+
+///
+/// Shared helper that resolves the Core assembly's informational version at startup, with the
+/// +build suffix stripped.
+///
+internal static class SdkVersion
+{
+ ///
+ /// The Core assembly version string (e.g. "0.0.1-alpha.1"), with any git commit hash
+ /// suffix (e.g. +abc123) removed. Falls back to the assembly's Version
+ /// property, and ultimately to "0.0.0" if neither attribute is present.
+ ///
+ internal static readonly string Value = BuildVersion();
+
+ private static string BuildVersion()
+ {
+ var version = typeof(SdkVersion).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion
+ ?? typeof(SdkVersion).Assembly.GetName().Version?.ToString()
+ ?? "0.0.0";
+
+ // Strip git commit hash suffix (e.g. "0.0.1-alpha.1+abc123" → "0.0.1-alpha.1").
+ var plusIndex = version.IndexOf('+', StringComparison.Ordinal);
+ return plusIndex >= 0 ? version[..plusIndex] : version;
+ }
+}
diff --git a/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs b/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs
new file mode 100644
index 0000000..0c2e496
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Pipeline/PipelineContext.cs
@@ -0,0 +1,110 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Diagnostics;
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Http.Response;
+
+namespace Dexpace.Sdk.Core.Pipeline;
+
+///
+/// Carries all per-call mutable state as it flows through the pipeline delegate chain.
+///
+///
+/// One instance is created per client call and passed to every policy in the chain. Policies
+/// read and replace (for redirect/auth rewriting), stash cross-policy
+/// coordination data in the property bag (see /
+/// ), and observe once the transport has
+/// responded.
+///
+public sealed class PipelineContext
+{
+ private Dictionary? _properties;
+
+ ///
+ /// Initializes a new for a single client call.
+ ///
+ /// The initial request. Policies may replace it during the call.
+ /// A snapshot of the client options for this call.
+ ///
+ /// An optional cancellation token that can abort the call.
+ ///
+ public PipelineContext(
+ Request request,
+ DexpaceClientOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(options);
+
+ Request = request;
+ Options = options;
+ CancellationToken = cancellationToken;
+ }
+
+ ///
+ /// The current request. Policies (e.g. redirect, auth) may replace this during the call.
+ ///
+ public Request Request { get; set; }
+
+ ///
+ /// The response from the transport, or before the transport responds.
+ /// Set by the pipeline after the outermost transport send completes.
+ ///
+ public Response? Response { get; internal set; }
+
+ ///
+ /// The active SDK tracing span, or when no
+ /// listener is registered (near-zero overhead when unobserved).
+ ///
+ public Activity? Activity { get; internal set; }
+
+ ///
+ /// A snapshot of the client options that applies to this call.
+ ///
+ public DexpaceClientOptions Options { get; }
+
+ ///
+ /// A token that can cancel the in-flight operation.
+ ///
+ public CancellationToken CancellationToken { get; }
+
+ ///
+ /// The zero-based retry attempt counter. 0 on the initial send;
+ /// incremented by the retry policy before each subsequent attempt.
+ ///
+ public int AttemptNumber { get; internal set; }
+
+ ///
+ /// Retrieves a typed value from the cross-policy property bag.
+ ///
+ /// The expected type of the stored value.
+ /// The key used when the value was stored.
+ ///
+ /// The stored value cast to , or
+ /// if the key is absent or the stored value cannot be cast.
+ ///
+ public T? GetProperty(string key)
+ {
+ if (_properties is null || !_properties.TryGetValue(key, out var value))
+ {
+ return default;
+ }
+
+ return value is T typed ? typed : default;
+ }
+
+ ///
+ /// Stores a typed value in the cross-policy property bag.
+ /// Overwrites any existing value for .
+ ///
+ /// The type of the value to store.
+ /// A key that identifies the value within this call.
+ /// The value to store.
+ public void SetProperty(string key, T value)
+ {
+ _properties ??= new Dictionary(StringComparer.Ordinal);
+ _properties[key] = value;
+ }
+}
diff --git a/tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs b/tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs
new file mode 100644
index 0000000..3c7a9b4
--- /dev/null
+++ b/tests/Dexpace.Sdk.Core.Tests/Configuration/DexpaceClientOptionsTests.cs
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Configuration;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Configuration;
+
+public class DexpaceClientOptionsTests
+{
+ [Fact]
+ public void DexpaceClientOptions_Defaults_AreCorrect()
+ {
+ var opts = new DexpaceClientOptions();
+
+ Assert.Null(opts.BaseAddress);
+ Assert.NotNull(opts.UserAgent);
+ Assert.StartsWith("dexpace-dotnet/", opts.UserAgent);
+ Assert.Null(opts.OverallTimeout);
+ Assert.Null(opts.AttemptTimeout);
+ Assert.NotNull(opts.Retry);
+ Assert.NotNull(opts.Redirect);
+ }
+
+ [Fact]
+ public void RetryOptions_Defaults_AreCorrect()
+ {
+ var retry = new RetryOptions();
+
+ Assert.Equal(3, retry.MaxRetryAttempts);
+ Assert.Equal(TimeSpan.FromMilliseconds(200), retry.BaseDelay);
+ Assert.Equal(TimeSpan.FromSeconds(30), retry.MaxDelay);
+ Assert.True(retry.HonorRetryAfter);
+ Assert.False(retry.RetryNonIdempotentWhenReplayable);
+ }
+
+ [Fact]
+ public void RedirectOptions_Defaults_AreCorrect()
+ {
+ var redirect = new RedirectOptions();
+
+ Assert.Equal(20, redirect.MaxRedirects);
+ Assert.False(redirect.AllowHttpsToHttpDowngrade);
+ Assert.True(redirect.StripSensitiveHeadersOnCrossOrigin);
+ }
+
+ [Fact]
+ public void DexpaceClientOptions_RetryAndRedirect_AreNonNullOnFreshInstance()
+ {
+ var opts = new DexpaceClientOptions();
+
+ // Property bag objects must be initialized — not null — so callers can do
+ // opts.Retry.MaxRetryAttempts = 5 without a null ref.
+ Assert.NotNull(opts.Retry);
+ Assert.NotNull(opts.Redirect);
+ }
+}
diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs
new file mode 100644
index 0000000..4357ceb
--- /dev/null
+++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/DexpaceDiagnosticsTests.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Diagnostics;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Diagnostics;
+
+public class DexpaceDiagnosticsTests
+{
+ [Fact]
+ public void ActivitySource_HasCorrectName()
+ {
+ Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.ActivitySource.Name);
+ }
+
+ [Fact]
+ public void Meter_HasCorrectName()
+ {
+ Assert.Equal("Dexpace.Sdk", DexpaceDiagnostics.Meter.Name);
+ }
+
+ [Fact]
+ public void ActivitySource_VersionIsNonEmpty()
+ {
+ Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.ActivitySource.Version));
+ }
+
+ [Fact]
+ public void Meter_VersionIsNonEmpty()
+ {
+ Assert.False(string.IsNullOrEmpty(DexpaceDiagnostics.Meter.Version));
+ }
+}
diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs
new file mode 100644
index 0000000..c8e349a
--- /dev/null
+++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs
@@ -0,0 +1,162 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Diagnostics;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Diagnostics;
+
+public class UrlRedactorTests
+{
+ // Use the default-set instance for most tests.
+ private static readonly UrlRedactor DefaultRedactor = new();
+
+ [Fact]
+ public void Redact_UserInfo_IsStripped()
+ {
+ var uri = new Uri("https://user:secret@api.example.com/path");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.DoesNotContain("user", result);
+ Assert.DoesNotContain("secret", result);
+ Assert.Contains("api.example.com", result);
+ }
+
+ [Fact]
+ public void Redact_SensitiveQueryParam_ValueIsReplaced()
+ {
+ var uri = new Uri("https://api.example.com/v1/items?access_token=super-secret&page=2");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("access_token=REDACTED", result);
+ Assert.Contains("page=2", result);
+ Assert.DoesNotContain("super-secret", result);
+ }
+
+ [Fact]
+ public void Redact_NonSensitiveQueryParam_IsPreserved()
+ {
+ var uri = new Uri("https://api.example.com/search?q=hello&lang=en");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("q=hello", result);
+ Assert.Contains("lang=en", result);
+ }
+
+ [Fact]
+ public void Redact_SensitiveParamCheck_IsCaseInsensitive()
+ {
+ var uri = new Uri("https://api.example.com/v1?API_KEY=abc123");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("API_KEY=REDACTED", result);
+ Assert.DoesNotContain("abc123", result);
+ }
+
+ [Fact]
+ public void Redact_NoQueryString_ReturnsSafeUrl()
+ {
+ var uri = new Uri("https://api.example.com/v1/resource");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Equal("https://api.example.com/v1/resource", result);
+ }
+
+ [Fact]
+ public void Redact_CustomSensitiveParams_AreRedacted()
+ {
+ var redactor = new UrlRedactor(["x-custom-secret"]);
+ var uri = new Uri("https://api.example.com/?x-custom-secret=mysecret&other=value");
+ var result = redactor.Redact(uri);
+
+ Assert.Contains("x-custom-secret=REDACTED", result);
+ Assert.Contains("other=value", result);
+ }
+
+ // ── Edge-case tests added by code-review hardening ────────────────────────
+
+ [Fact]
+ public void Redact_RelativeUri_SensitiveQueryParamIsRedacted_NoException()
+ {
+ // A relative URI carrying a sensitive query param must not throw and must not leak.
+ var uri = new Uri("/v1/items?token=super-secret&page=2", UriKind.Relative);
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("token=REDACTED", result);
+ Assert.DoesNotContain("super-secret", result);
+ Assert.Contains("page=2", result);
+ Assert.Contains("/v1/items", result);
+ }
+
+ [Fact]
+ public void Redact_Fragment_IsDropped()
+ {
+ // Fragments should be stripped from absolute URIs.
+ var uri = new Uri("https://api.example.com/path?q=hello#section");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.DoesNotContain("#section", result);
+ Assert.Contains("q=hello", result);
+ }
+
+ [Fact]
+ public void Redact_RelativeUri_Fragment_IsDropped()
+ {
+ // Fragments should be stripped from relative URIs too.
+ var uri = new Uri("/path?q=hello#section", UriKind.Relative);
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.DoesNotContain("#section", result);
+ Assert.Contains("q=hello", result);
+ }
+
+ [Fact]
+ public void Redact_ValuelessParam_DoesNotCrash_AndSensitiveParamIsStillRedacted()
+ {
+ // "?flag" has no '=' so it is skipped (no value to leak); token must still be redacted.
+ var uri = new Uri("https://api.example.com/v1?flag&token=x");
+ var result = DefaultRedactor.Redact(uri);
+
+ // The bare flag is dropped (no value — safe to omit).
+ Assert.DoesNotContain("flag=", result);
+ // The sensitive token value must not appear.
+ Assert.Contains("token=REDACTED", result);
+ Assert.DoesNotContain("=x", result);
+ }
+
+ [Fact]
+ public void Redact_RepeatedSensitiveParam_BothValuesAreRedacted()
+ {
+ // Both occurrences of a repeated sensitive param must be redacted.
+ var uri = new Uri("https://api.example.com/v1?token=A&token=B");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.DoesNotContain("=A", result);
+ Assert.DoesNotContain("=B", result);
+ // Both occurrences of the key should appear, both redacted.
+ Assert.Equal(2, result.Split("token=REDACTED").Length - 1);
+ }
+
+ [Fact]
+ public void Redact_PercentEncodedSensitiveValue_IsRedacted()
+ {
+ // A percent-encoded sensitive value must still be caught and redacted.
+ var uri = new Uri("https://api.example.com/v1?token=my%2Fsecret%3Dvalue");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("token=REDACTED", result);
+ Assert.DoesNotContain("secret", result);
+ }
+
+ [Fact]
+ public void Redact_PathSegmentThatLooksSecret_IsPreservedVerbatim()
+ {
+ // Path components are NOT inspected — secrets in the path are the caller's responsibility.
+ // This test documents the boundary: path is preserved, no redaction is applied there.
+ var uri = new Uri("https://api.example.com/token/super-secret-value?page=1");
+ var result = DefaultRedactor.Redact(uri);
+
+ Assert.Contains("/token/super-secret-value", result);
+ Assert.Contains("page=1", result);
+ }
+}
diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs
new file mode 100644
index 0000000..2ac0680
--- /dev/null
+++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/PipelineContextTests.cs
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Http.Common;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Pipeline;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Pipeline;
+
+public class PipelineContextTests
+{
+ private static Request MakeRequest() =>
+ Request.Get("https://api.example.com/v1/resource");
+
+ [Fact]
+ public void Constructor_StoresRequest()
+ {
+ var request = MakeRequest();
+ var options = new DexpaceClientOptions();
+ var ctx = new PipelineContext(request, options);
+
+ Assert.Same(request, ctx.Request);
+ }
+
+ [Fact]
+ public void Constructor_StoresOptions()
+ {
+ var options = new DexpaceClientOptions { UserAgent = "test-agent/1.0" };
+ var ctx = new PipelineContext(MakeRequest(), options);
+
+ Assert.Same(options, ctx.Options);
+ }
+
+ [Fact]
+ public void Constructor_StoresCancellationToken()
+ {
+ using var cts = new CancellationTokenSource();
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions(), cts.Token);
+
+ Assert.Equal(cts.Token, ctx.CancellationToken);
+ }
+
+ [Fact]
+ public void Constructor_DefaultCancellationToken_IsDefault()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Equal(CancellationToken.None, ctx.CancellationToken);
+ }
+
+ [Fact]
+ public void AttemptNumber_DefaultsToZero()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Equal(0, ctx.AttemptNumber);
+ }
+
+ [Fact]
+ public void Response_DefaultsToNull()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Null(ctx.Response);
+ }
+
+ [Fact]
+ public void Activity_DefaultsToNull()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+
+ Assert.Null(ctx.Activity);
+ }
+
+ [Fact]
+ public void PropertyBag_RoundTrips_TypedValue()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+ ctx.SetProperty("idempotency-key", "idem-abc123");
+ var retrieved = ctx.GetProperty("idempotency-key");
+
+ Assert.Equal("idem-abc123", retrieved);
+ }
+
+ [Fact]
+ public void PropertyBag_MissingKey_ReturnsDefault()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+ var result = ctx.GetProperty("nonexistent");
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public void PropertyBag_MissingKeyForReferenceType_ReturnsNull()
+ {
+ var ctx = new PipelineContext(MakeRequest(), new DexpaceClientOptions());
+ var result = ctx.GetProperty("nonexistent");
+
+ Assert.Null(result);
+ }
+}