diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e7873d4..eec47c3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ permissions:
jobs:
build-test:
runs-on: dotnet
- timeout-minutes: 20
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
diff --git a/Compendium.sln b/Compendium.sln
index 933c44e..3386207 100644
--- a/Compendium.sln
+++ b/Compendium.sln
@@ -191,6 +191,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub.Tests", "tests\Unit\Compendium.Adapters.GitHub.Tests\Compendium.Adapters.GitHub.Tests.csproj", "{5FE1601C-93E1-4E92-9AC9-521AA26592CB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Secrets", "src\Abstractions\Compendium.Abstractions.Secrets\Compendium.Abstractions.Secrets.csproj", "{7A60AF89-1328-4BC4-96F9-9D7113D49F1A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Secrets.Tests", "tests\Unit\Compendium.Abstractions.Secrets.Tests\Compendium.Abstractions.Secrets.Tests.csproj", "{BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Scaleway.SecretManager", "src\Adapters\Compendium.Adapters.Scaleway.SecretManager\Compendium.Adapters.Scaleway.SecretManager.csproj", "{7EB70764-89D0-4855-BB8C-8B7F03268D4D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Scaleway.SecretManager.Tests", "tests\Unit\Compendium.Adapters.Scaleway.SecretManager.Tests\Compendium.Adapters.Scaleway.SecretManager.Tests.csproj", "{18986C9E-5284-4356-A5FE-0F642CBA1F3D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -496,6 +504,22 @@ Global
{5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6}
@@ -585,5 +609,9 @@ Global
{C0C1EAC1-5C33-4451-8CD6-2521A8D13557} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
{4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB}
{5FE1601C-93E1-4E92-9AC9-521AA26592CB} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
+ {7A60AF89-1328-4BC4-96F9-9D7113D49F1A} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2}
+ {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
+ {7EB70764-89D0-4855-BB8C-8B7F03268D4D} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB}
+ {18986C9E-5284-4356-A5FE-0F642CBA1F3D} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
EndGlobalSection
EndGlobal
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapabilities.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapabilities.cs
new file mode 100644
index 0000000..f0673d6
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapabilities.cs
@@ -0,0 +1,75 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Capabilities;
+
+///
+/// The support level an adapter declares for a .
+///
+public enum SecretVaultCapabilityLevel
+{
+ /// The capability is not available on this provider.
+ None,
+
+ /// The capability is available with limitations (documented in the adapter's CAPABILITIES.md).
+ Partial,
+
+ /// The capability is fully supported.
+ Full,
+}
+
+///
+/// An adapter's declared support for a single capability.
+///
+/// The support level.
+///
+/// A short human-readable limitation note, required when
+/// is or when a conceptually
+/// available capability is declared
+/// for adapter-specific reasons.
+///
+public sealed record SecretVaultCapabilitySupport(SecretVaultCapabilityLevel Level, string? Limitation = null);
+
+///
+/// The declarative capability matrix of a secret-vault adapter. Consumers use
+/// it to drive UI affordances (hide or disable unsupported features with a
+/// reason) and adapters use to fail uniformly.
+///
+public sealed record SecretVaultCapabilities
+{
+ ///
+ /// Gets the provider identifier these capabilities describe (matches
+ /// ).
+ ///
+ public required string Provider { get; init; }
+
+ ///
+ /// Gets the declared support per capability. Capabilities absent from the
+ /// dictionary are treated as .
+ ///
+ public required IReadOnlyDictionary Entries { get; init; }
+
+ ///
+ /// Returns whether the capability is available at any level
+ /// ( or ).
+ ///
+ public bool Supports(SecretVaultCapability capability) =>
+ Entries.TryGetValue(capability, out var support) && support.Level != SecretVaultCapabilityLevel.None;
+
+ ///
+ /// Returns success when the capability is available, otherwise the standard
+ /// SecretVault.CapabilityNotSupported failure. Adapters call this at
+ /// the top of optional-capability methods so every provider fails identically.
+ ///
+ public Result EnsureSupported(SecretVaultCapability capability) =>
+ Supports(capability)
+ ? Result.Success()
+ : Result.Failure(SecretVaultErrors.NotSupported(
+ Provider,
+ capability,
+ Entries.TryGetValue(capability, out var support) ? support.Limitation : null));
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapability.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapability.cs
new file mode 100644
index 0000000..3e1428c
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapability.cs
@@ -0,0 +1,65 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Capabilities;
+
+///
+/// The optional capabilities a secret-vault adapter can declare. Consumers use
+/// the declared matrix to drive UI affordances and to select providers;
+/// adapters use to fail
+/// uniformly when an undeclared capability is invoked.
+///
+public enum SecretVaultCapability
+{
+ ///
+ /// Versions are immutable once written: the material of a revision can
+ /// never change, so a revision reference is a stable rollback anchor.
+ /// This is the core contract; adapters that cannot guarantee it must not
+ /// declare it, and callers requiring stable history must refuse them.
+ ///
+ ImmutableVersions,
+
+ ///
+ /// Individual revisions can be disabled (kill-switch for a leaked value)
+ /// and re-enabled without destroying the material.
+ ///
+ VersionEnableDisable,
+
+ ///
+ /// Individual revisions can be permanently destroyed (retention/cost
+ /// control). Destroyed revisions read as not found.
+ ///
+ VersionDestroy,
+
+ ///
+ /// Secrets are organized in a hierarchical path namespace that supports
+ /// prefix listing.
+ ///
+ PathHierarchy,
+
+ ///
+ /// Secrets carry provider-side key/value tags usable as list filters.
+ ///
+ Tags,
+
+ ///
+ /// The provider accepts payloads larger than 64 KiB per version.
+ ///
+ LargePayload,
+
+ ///
+ /// The provider supports ephemeral (auto-expiring) secrets. Not surfaced
+ /// by the v1 ports; declared for capability-driven UI only.
+ ///
+ EphemeralSecrets,
+
+ ///
+ /// The provider can rotate secret material server-side on a schedule. Not
+ /// surfaced by the v1 ports; declared for capability-driven UI only.
+ ///
+ ServerSideRotation,
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Compendium.Abstractions.Secrets.csproj b/src/Abstractions/Compendium.Abstractions.Secrets/Compendium.Abstractions.Secrets.csproj
new file mode 100644
index 0000000..7dd5a1b
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Compendium.Abstractions.Secrets.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Compendium.Abstractions.Secrets
+ Compendium.Abstractions.Secrets
+ Compendium.Abstractions.Secrets
+ Secret-vault abstractions for Compendium Framework: the ISecretVault facade with container and immutable-version ports, a provider-neutral connection/credential model, and declarative per-adapter capabilities. Versions are addressed by explicit revision (never "latest"), which is what makes value history and rollback stable across providers. Provider-agnostic ports for Scaleway Secret Manager, HashiCorp Vault, cloud key vaults, and in-house stores.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultConnection.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultConnection.cs
new file mode 100644
index 0000000..6595699
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultConnection.cs
@@ -0,0 +1,44 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Connections;
+
+///
+/// Everything an adapter needs to reach one vault tenancy on one provider.
+/// Passed explicitly to every port method so adapters stay stateless
+/// singletons and a single adapter instance can serve many tenancies
+/// (platform-owned today, per-tenant or BYO-vault later) without contract
+/// changes.
+///
+public sealed record SecretVaultConnection
+{
+ ///
+ /// Gets the provider identifier this connection targets (matches
+ /// ).
+ ///
+ public required string Provider { get; init; }
+
+ ///
+ /// Gets the provider-side tenancy container the connection is scoped to
+ /// (Scaleway: the project id; Vault: a namespace).
+ /// when the provider has no tenancy concept or the adapter's configured
+ /// default applies.
+ ///
+ public string? Tenancy { get; init; }
+
+ ///
+ /// Gets the provider region hosting the tenancy, when regional
+ /// (e.g. "fr-par"). selects the adapter's
+ /// configured default.
+ ///
+ public string? Region { get; init; }
+
+ ///
+ /// Gets the credential material used to authenticate.
+ ///
+ public required SecretVaultCredential Credential { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultCredential.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultCredential.cs
new file mode 100644
index 0000000..e6e81da
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultCredential.cs
@@ -0,0 +1,37 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Connections;
+
+///
+/// The credential material of a , as a
+/// closed union. Token-bearing members redact their material in
+/// ToString() so a logged connection never leaks a secret.
+///
+public abstract record SecretVaultCredential
+{
+ private SecretVaultCredential()
+ {
+ }
+
+ ///
+ /// A durable API token or key (Scaleway IAM API key secret, Vault token,
+ /// cloud key-vault access key).
+ ///
+ /// The token material. Redacted in ToString().
+ public sealed record ApiToken(string Token) : SecretVaultCredential
+ {
+ ///
+ public override string ToString() => "ApiToken(***)";
+ }
+
+ ///
+ /// No credential material: for in-memory and null vaults, or adapters that
+ /// authenticate ambiently (workload identity, instance profile).
+ ///
+ public sealed record None : SecretVaultCredential;
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/GlobalUsings.cs b/src/Abstractions/Compendium.Abstractions.Secrets/GlobalUsings.cs
new file mode 100644
index 0000000..d2393ca
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/GlobalUsings.cs
@@ -0,0 +1,8 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+global using Compendium.Core.Results;
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/ISecretVault.cs b/src/Abstractions/Compendium.Abstractions.Secrets/ISecretVault.cs
new file mode 100644
index 0000000..b53bd70
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/ISecretVault.cs
@@ -0,0 +1,43 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Abstractions.Secrets.Services;
+
+namespace Compendium.Abstractions.Secrets;
+
+///
+/// The secret-vault facade: one instance per provider adapter, carrying the
+/// provider discriminator, the declarative capability matrix, and the
+/// concern-scoped ports. Consumers resolve IEnumerable<ISecretVault>
+/// and dispatch on (the sub-ports are also registered
+/// individually for single-concern consumers).
+///
+///
+/// Implementations are stateless singletons: all tenancy state travels in the
+/// passed to every method, so
+/// a single adapter instance serves any number of tenancies concurrently.
+///
+public interface ISecretVault
+{
+ ///
+ /// Gets the provider identifier used for dispatch (e.g. "scaleway",
+ /// "vault", "inmemory").
+ ///
+ string Provider { get; }
+
+ ///
+ /// Gets the adapter's declared capability matrix.
+ ///
+ SecretVaultCapabilities Capabilities { get; }
+
+ /// Gets the secret container lifecycle port.
+ ISecretContainerService Secrets { get; }
+
+ /// Gets the immutable-version port.
+ ISecretVersionService Versions { get; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/CreateVaultSecret.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/CreateVaultSecret.cs
new file mode 100644
index 0000000..2867c96
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/CreateVaultSecret.cs
@@ -0,0 +1,40 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Model;
+
+///
+/// The request to create a secret container (which holds versions, but no
+/// material yet — write the first version with
+/// ).
+///
+public sealed record CreateVaultSecret
+{
+ ///
+ /// Gets the secret name, unique within its . Diagnostic
+ /// and console-facing only — the provider-side id returned at creation is
+ /// the identity.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the hierarchical path the secret lives under.
+ ///
+ public required SecretScopePath Path { get; init; }
+
+ ///
+ /// Gets an optional human-readable description.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Gets optional provider-side tags (list filters, reconciliation
+ /// markers). Adapters without the
+ /// capability ignore them.
+ ///
+ public IReadOnlyDictionary? Tags { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretMaterial.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretMaterial.cs
new file mode 100644
index 0000000..ad5bb3a
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretMaterial.cs
@@ -0,0 +1,58 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Text;
+
+namespace Compendium.Abstractions.Secrets.Model;
+
+///
+/// The payload of one secret version. Wraps the raw bytes so the value can
+/// never leak through ToString() (logging, interpolation, exception
+/// messages). No zeroing guarantees are made — the .NET GC copies freely —
+/// so treat any process that held a material as having seen the plaintext.
+///
+public sealed class SecretMaterial
+{
+ private readonly byte[] _data;
+
+ private SecretMaterial(byte[] data)
+ {
+ _data = data;
+ }
+
+ ///
+ /// Gets the raw payload bytes.
+ ///
+ public ReadOnlyMemory Data => _data;
+
+ ///
+ /// Gets the payload size in bytes.
+ ///
+ public int Length => _data.Length;
+
+ ///
+ /// Wraps raw bytes as secret material. The array is copied so later
+ /// mutation of the source cannot alter the material.
+ ///
+ public static SecretMaterial FromBytes(ReadOnlySpan data) => new(data.ToArray());
+
+ ///
+ /// Wraps a UTF-8 string value as secret material.
+ ///
+ public static SecretMaterial FromString(string value) => new(Encoding.UTF8.GetBytes(value));
+
+ ///
+ /// Decodes the material as a UTF-8 string.
+ ///
+ public string AsString() => Encoding.UTF8.GetString(_data);
+
+ ///
+ /// Always redacts; the payload is never representable as text through this
+ /// type's string form.
+ ///
+ public override string ToString() => "SecretMaterial(***)";
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretScopePath.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretScopePath.cs
new file mode 100644
index 0000000..1eec351
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretScopePath.cs
@@ -0,0 +1,62 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Model;
+
+///
+/// A validated hierarchical path (/segment/segment/...) locating a
+/// secret inside a vault tenancy. The path is organizational only — callers
+/// define their own layout conventions; adapters map it onto the provider's
+/// folder/path concept (or a name prefix when the provider has none). The
+/// path is never the secret's identity: consumers must address secrets by the
+/// provider-side id returned at creation.
+///
+public sealed record SecretScopePath
+{
+ private SecretScopePath(IReadOnlyList segments)
+ {
+ Segments = segments;
+ }
+
+ ///
+ /// Gets the root path (/).
+ ///
+ public static SecretScopePath Root { get; } = new([]);
+
+ ///
+ /// Gets the ordered path segments (empty for the root path).
+ ///
+ public IReadOnlyList Segments { get; }
+
+ ///
+ /// Builds a path from validated segments: each must be non-empty, at most
+ /// 100 characters, and restricted to letters, digits, -, _
+ /// and . (no separators, no whitespace).
+ ///
+ public static Result From(params string[] segments)
+ {
+ foreach (var segment in segments)
+ {
+ if (string.IsNullOrWhiteSpace(segment) || segment.Length > 100 ||
+ !segment.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.'))
+ {
+ return Result.Failure(Error.Validation(
+ $"{SecretVaultErrors.Prefix}.InvalidPathSegment",
+ $"Invalid path segment '{segment}': segments must be 1-100 characters of letters, digits, '-', '_' or '.'."));
+ }
+ }
+
+ return Result.Success(new SecretScopePath([.. segments]));
+ }
+
+ ///
+ /// Returns the canonical string form: /-prefixed joined segments,
+ /// or / for the root.
+ ///
+ public override string ToString() =>
+ Segments.Count == 0 ? "/" : "/" + string.Join('/', Segments);
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretDescriptor.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretDescriptor.cs
new file mode 100644
index 0000000..2a3883c
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretDescriptor.cs
@@ -0,0 +1,52 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Model;
+
+///
+/// A secret container as the provider reports it. Carries no material.
+///
+public sealed record VaultSecretDescriptor
+{
+ ///
+ /// Gets the provider-side identifier. This is the secret's identity:
+ /// consumers persist and address by this id, never by name/path.
+ ///
+ public required string SecretId { get; init; }
+
+ ///
+ /// Gets the secret name (unique within ).
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the hierarchical path the secret lives under.
+ ///
+ public required SecretScopePath Path { get; init; }
+
+ ///
+ /// Gets the human-readable description, when set.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Gets the provider-side tags (empty when untagged or unsupported).
+ ///
+ public IReadOnlyDictionary Tags { get; init; } =
+ new Dictionary();
+
+ ///
+ /// Gets the number of versions the provider reports for this secret,
+ /// when known.
+ ///
+ public long? VersionCount { get; init; }
+
+ ///
+ /// Gets the creation timestamp, when the provider reports one.
+ ///
+ public DateTimeOffset? CreatedAt { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretVersion.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretVersion.cs
new file mode 100644
index 0000000..c8ee77f
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretVersion.cs
@@ -0,0 +1,52 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Abstractions.Secrets.Model;
+
+///
+/// The lifecycle state of a single secret version.
+///
+public enum VaultSecretVersionStatus
+{
+ /// The version is readable.
+ Enabled,
+
+ /// The version is kill-switched: access is refused until re-enabled.
+ Disabled,
+
+ /// The material was permanently destroyed; the revision number remains reserved.
+ Destroyed,
+}
+
+///
+/// One immutable version of a secret, identified by its monotonically
+/// increasing (1-based). A revision number is never
+/// reused, which is what makes (secretId, revision) a stable value
+/// reference for history and rollback.
+///
+public sealed record VaultSecretVersion
+{
+ ///
+ /// Gets the 1-based, monotonically increasing revision number.
+ ///
+ public required long Revision { get; init; }
+
+ ///
+ /// Gets the version's lifecycle state.
+ ///
+ public required VaultSecretVersionStatus Status { get; init; }
+
+ ///
+ /// Gets the description recorded when the version was written, when any.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Gets the creation timestamp, when the provider reports one.
+ ///
+ public DateTimeOffset? CreatedAt { get; init; }
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/README.md b/src/Abstractions/Compendium.Abstractions.Secrets/README.md
new file mode 100644
index 0000000..e745bfa
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/README.md
@@ -0,0 +1,38 @@
+# Compendium.Abstractions.Secrets
+
+Provider-agnostic secret-vault abstractions: the `ISecretVault` facade with two
+concern-scoped ports (`ISecretContainerService`, `ISecretVersionService`), a
+neutral connection/credential model, and declarative per-adapter capabilities.
+
+## Design contract
+
+- **Versions are immutable and addressed by explicit revision.** The port has
+ no "latest" concept: which revision is *current* is the caller's metadata.
+ This makes `(secretId, revision)` a stable value reference — history stays
+ trustworthy and rollback is a re-pointing of references, never a provider-side
+ mutation.
+- **The provider-side `SecretId` is the identity.** Names and paths are
+ organizational/diagnostic; consumers persist ids.
+- **No secret ever stringifies.** `SecretMaterial` and token credentials redact
+ in `ToString()`.
+- **Errors are uniform** (`SecretVault.*` codes via the Result pattern — never
+ exceptions for control flow), so consumers handle Scaleway, Vault, or an
+ in-house store identically.
+- **Capabilities are declarative.** Adapters declare `ImmutableVersions`,
+ `VersionEnableDisable`, `VersionDestroy`, `PathHierarchy`, `Tags`,
+ `LargePayload`, … and fail undeclared operations with
+ `SecretVault.CapabilityNotSupported`; each adapter documents its matrix in
+ `CAPABILITIES.md`.
+
+## Testing
+
+`Compendium.Testing` ships `InMemorySecretVault` (a full-fidelity fake) and
+`SecretVaultContractTests`, the behavioral contract suite every adapter must
+pass — inheriting it in an adapter's test suite is what makes swapping vault
+backends safe.
+
+## Adapters
+
+- `Compendium.Adapters.Scaleway.SecretManager` — Scaleway Secret Manager
+ (regional, immutable versions, tags/paths).
+- `NullSecretVault` (in this package) — fail-fast stub for unconfigured hosts.
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/SecretVaultErrors.cs b/src/Abstractions/Compendium.Abstractions.Secrets/SecretVaultErrors.cs
new file mode 100644
index 0000000..4b2082f
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/SecretVaultErrors.cs
@@ -0,0 +1,157 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Capabilities;
+
+namespace Compendium.Abstractions.Secrets;
+
+///
+/// Provides standardized error definitions for secret-vault operations.
+/// Adapters map provider responses onto these errors so consumers can handle
+/// failures uniformly across providers.
+///
+public static class SecretVaultErrors
+{
+ ///
+ /// Gets the error code prefix for secret-vault errors.
+ ///
+ public const string Prefix = "SecretVault";
+
+ ///
+ /// No secret vault is configured on this host (missing credentials,
+ /// project, or region). Returned by and
+ /// by adapters whose required options are absent.
+ ///
+ public static Error NotConfigured(string? provider = null) =>
+ Error.Unavailable(
+ $"{Prefix}.NotConfigured",
+ provider is null
+ ? "No secret vault is configured on this host."
+ : $"The '{provider}' secret vault is not configured on this host.");
+
+ ///
+ /// The operation requires a capability the provider does not support (or
+ /// supports only partially). Carries provider and capability
+ /// metadata; see the adapter's CAPABILITIES.md for the support matrix.
+ ///
+ public static Error NotSupported(string provider, SecretVaultCapability capability, string? limitation = null)
+ {
+ var metadata = new Dictionary
+ {
+ ["provider"] = provider,
+ ["capability"] = capability.ToString(),
+ };
+ if (limitation is not null)
+ {
+ metadata["limitation"] = limitation;
+ }
+
+ return Error.Unavailable(
+ $"{Prefix}.CapabilityNotSupported",
+ limitation is null
+ ? $"Provider '{provider}' does not support capability '{capability}'."
+ : $"Provider '{provider}' does not support capability '{capability}': {limitation}",
+ metadata);
+ }
+
+ ///
+ /// The vault rejected the caller's credentials (expired key, revoked
+ /// application, wrong tenancy).
+ ///
+ public static Error AuthenticationFailed(string provider, string? detail = null) =>
+ Error.Unauthorized(
+ $"{Prefix}.AuthenticationFailed",
+ detail is null
+ ? $"Authentication with the '{provider}' secret vault failed."
+ : $"Authentication with the '{provider}' secret vault failed: {detail}");
+
+ ///
+ /// No secret exists for the given provider-side identifier (or it has been
+ /// deleted).
+ ///
+ public static Error SecretNotFound(string secretId) =>
+ Error.NotFound(
+ $"{Prefix}.SecretNotFound",
+ $"Secret '{secretId}' was not found in the vault.");
+
+ ///
+ /// The secret exists but has no revision with the given number (never
+ /// written, or destroyed).
+ ///
+ public static Error VersionNotFound(string secretId, long revision) =>
+ Error.NotFound(
+ $"{Prefix}.VersionNotFound",
+ $"Revision {revision} of secret '{secretId}' was not found (never written or destroyed).");
+
+ ///
+ /// The revision exists but is disabled (kill-switch). Access is refused
+ /// until the revision is re-enabled.
+ ///
+ public static Error VersionDisabled(string secretId, long revision) =>
+ Error.Conflict(
+ $"{Prefix}.VersionDisabled",
+ $"Revision {revision} of secret '{secretId}' is disabled and cannot be accessed.");
+
+ ///
+ /// A secret with the same name already exists at the given path.
+ ///
+ public static Error ConflictExists(string name, string path) =>
+ Error.Conflict(
+ $"{Prefix}.ConflictExists",
+ $"A secret named '{name}' already exists at path '{path}'.");
+
+ ///
+ /// The payload exceeds the provider's per-version size limit. Carries
+ /// size and maxSize metadata.
+ ///
+ public static Error PayloadTooLarge(int size, int maxSize) =>
+ Error.Validation(
+ $"{Prefix}.PayloadTooLarge",
+ $"Secret payload of {size} bytes exceeds the provider limit of {maxSize} bytes.",
+ new Dictionary { ["size"] = size, ["maxSize"] = maxSize });
+
+ ///
+ /// A provider-side quota was exhausted (secrets per project, versions per
+ /// secret, ...). Not retryable without operator action.
+ ///
+ public static Error QuotaExceeded(string provider, string detail) =>
+ Error.Unavailable(
+ $"{Prefix}.QuotaExceeded",
+ $"Provider '{provider}' quota exceeded: {detail}");
+
+ ///
+ /// The provider throttled the request. Carries retryAfterSeconds
+ /// metadata when the provider supplied a Retry-After hint.
+ ///
+ public static Error Throttled(string provider, int? retryAfterSeconds = null)
+ {
+ var metadata = new Dictionary { ["provider"] = provider };
+ if (retryAfterSeconds is not null)
+ {
+ metadata["retryAfterSeconds"] = retryAfterSeconds.Value;
+ }
+
+ return Error.TooManyRequests(
+ $"{Prefix}.Throttled",
+ retryAfterSeconds is null
+ ? $"Provider '{provider}' throttled the request."
+ : $"Provider '{provider}' throttled the request; retry after {retryAfterSeconds.Value}s.",
+ metadata);
+ }
+
+ ///
+ /// The provider rejected the request for a reason not covered by a more
+ /// specific error. Carries provider and statusCode metadata.
+ ///
+ public static Error ProviderRejected(string provider, int statusCode, string? detail = null) =>
+ Error.Failure(
+ $"{Prefix}.ProviderRejected",
+ detail is null
+ ? $"Provider '{provider}' rejected the request with status {statusCode}."
+ : $"Provider '{provider}' rejected the request with status {statusCode}: {detail}",
+ new Dictionary { ["provider"] = provider, ["statusCode"] = statusCode });
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretContainerService.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretContainerService.cs
new file mode 100644
index 0000000..8c5a846
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretContainerService.cs
@@ -0,0 +1,60 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+
+namespace Compendium.Abstractions.Secrets.Services;
+
+///
+/// Secret container lifecycle: create, describe, list, delete. Containers
+/// hold versions (); they carry no
+/// material themselves.
+///
+public interface ISecretContainerService
+{
+ ///
+ /// Creates a secret container. Fails with
+ /// SecretVault.ConflictExists when a secret with the same name
+ /// already exists at the same path.
+ ///
+ Task> CreateAsync(
+ SecretVaultConnection connection,
+ CreateVaultSecret request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Describes a secret by its provider-side id.
+ ///
+ Task> GetAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists secrets under a path prefix, optionally filtered by tags
+ /// (all given tags must match). Adapters without
+ /// treat the
+ /// path as an exact match; adapters without
+ /// fail when a tag
+ /// filter is supplied.
+ ///
+ Task>> ListAsync(
+ SecretVaultConnection connection,
+ SecretScopePath pathPrefix,
+ IReadOnlyDictionary? tagFilter = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a secret container and every version it holds. Idempotent:
+ /// deleting an unknown id succeeds.
+ ///
+ Task DeleteAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretVersionService.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretVersionService.cs
new file mode 100644
index 0000000..c86357e
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretVersionService.cs
@@ -0,0 +1,85 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+
+namespace Compendium.Abstractions.Secrets.Services;
+
+///
+/// Immutable secret versions. Every read addresses an explicit revision —
+/// the port has no "latest" concept, deliberately: which revision is current
+/// is the caller's metadata, so history stays stable and rollback is a
+/// re-pointing of references, independent of provider-side mutable state.
+///
+public interface ISecretVersionService
+{
+ ///
+ /// Appends an immutable version and returns it with its stable revision
+ /// number (the rollback anchor). Not safely retryable after the payload
+ /// was sent — callers own idempotency (deduplicate by content hash before
+ /// writing).
+ ///
+ Task> AddAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ SecretMaterial material,
+ string? description = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Reads the material of an exact revision. Revisions are immutable, so a
+ /// successful read is cacheable indefinitely by the caller. Fails with
+ /// SecretVault.VersionDisabled on a kill-switched revision and
+ /// SecretVault.VersionNotFound on a destroyed or unknown one.
+ ///
+ Task> AccessAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ long revision,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists the versions of a secret (metadata only, no material).
+ ///
+ Task>> ListAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Re-enables a disabled revision. Idempotent on an already-enabled one.
+ /// Requires .
+ ///
+ Task EnableAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ long revision,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Kill-switches a revision: its material stops being readable until
+ /// re-enabled. Idempotent on an already-disabled one. Requires
+ /// .
+ ///
+ Task DisableAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ long revision,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Permanently destroys a revision's material (retention/cost control).
+ /// The revision number stays reserved and subsequently reads as not
+ /// found. Requires .
+ ///
+ Task DestroyAsync(
+ SecretVaultConnection connection,
+ string secretId,
+ long revision,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Stubs/NullSecretVault.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Stubs/NullSecretVault.cs
new file mode 100644
index 0000000..a9ba1f3
--- /dev/null
+++ b/src/Abstractions/Compendium.Abstractions.Secrets/Stubs/NullSecretVault.cs
@@ -0,0 +1,95 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Abstractions.Secrets.Services;
+
+namespace Compendium.Abstractions.Secrets.Stubs;
+
+///
+/// The fail-fast vault registered when no provider is configured: every
+/// operation fails with SecretVault.NotConfigured. Keeps consumers
+/// honest — a missing configuration surfaces as a uniform, diagnosable error
+/// instead of a null reference or a silent no-op.
+///
+public sealed class NullSecretVault : ISecretVault, ISecretContainerService, ISecretVersionService
+{
+ ///
+ /// The provider discriminator of the null vault.
+ ///
+ public const string ProviderName = "null";
+
+ ///
+ public string Provider => ProviderName;
+
+ ///
+ public SecretVaultCapabilities Capabilities { get; } = new()
+ {
+ Provider = ProviderName,
+ Entries = new Dictionary(),
+ };
+
+ ///
+ public ISecretContainerService Secrets => this;
+
+ ///
+ public ISecretVersionService Versions => this;
+
+ ///
+ public Task> CreateAsync(
+ SecretVaultConnection connection, CreateVaultSecret request, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task> GetAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task>> ListAsync(
+ SecretVaultConnection connection, SecretScopePath pathPrefix,
+ IReadOnlyDictionary? tagFilter = null, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure>(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task DeleteAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task> AddAsync(
+ SecretVaultConnection connection, string secretId, SecretMaterial material,
+ string? description = null, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task> AccessAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task>> ListAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure>(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task EnableAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task DisableAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+
+ ///
+ public Task DestroyAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName)));
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/CAPABILITIES.md b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/CAPABILITIES.md
new file mode 100644
index 0000000..c48d5c9
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/CAPABILITIES.md
@@ -0,0 +1,37 @@
+# Scaleway Secret Manager — capability matrix
+
+Adapter: `Compendium.Adapters.Scaleway.SecretManager` · Provider id: `scaleway`
+API: Secret Manager regional `v1beta1` (`fr-par`, `nl-ams`, `pl-waw`)
+
+| Capability | Level | Notes |
+|---|---|---|
+| `ImmutableVersions` | **Full** | Versions are immutable; revisions are 1-based, monotonic, never reused. `(secretId, revision)` is a stable rollback anchor. |
+| `VersionEnableDisable` | **Full** | Kill-switch per revision. The adapter makes enable/disable idempotent (a no-op transition reads as success). |
+| `VersionDestroy` | **Full** | Permanent destruction; the revision number stays reserved. |
+| `PathHierarchy` | **Full** | Secrets live in `/`-separated folders. Prefix listing is filtered adapter-side over the tenancy's secrets (paged at 100/page), keeping prefix semantics uniform across providers. |
+| `Tags` | **Partial** | The provider models tags as a list of strings; the neutral key/value tags are encoded as `key:value` entries (colon-less tags decode to an empty value). Tag filters apply adapter-side. |
+| `LargePayload` | **None** | A version payload is limited to 64 KiB; larger payloads fail fast with `SecretVault.PayloadTooLarge` before any network call. |
+| `EphemeralSecrets` | **None** | Exists provider-side (ephemeral policies); not exposed through the v1 ports. |
+| `ServerSideRotation` | **None** | Not provided by Secret Manager. |
+
+## Operational notes
+
+- **Authentication**: IAM API secret key sent as `X-Auth-Token`, supplied per
+ call via `SecretVaultConnection.Credential` (`ApiToken`). The adapter holds
+ no credential state; options carry only base URL, default region, default
+ project.
+- **Tenancy**: `SecretVaultConnection.Tenancy` = the Scaleway project id
+ (falls back to `DefaultProjectId`).
+- **Retries**: requests are sent exactly once. Version writes are not
+ idempotent; retry/deduplication policy belongs to the caller (deduplicate by
+ content hash before writing).
+- **Throttling**: HTTP 429 maps to `SecretVault.Throttled` carrying
+ `retryAfterSeconds` when the provider supplies `Retry-After`.
+- **Disabled-version access**: the provider's 4xx on access is disambiguated
+ by reading the version metadata, so consumers always receive the precise
+ `VersionDisabled` vs `VersionNotFound` vs `SecretNotFound` code.
+- **Billing**: Scaleway bills stored versions (enabled or disabled) and API
+ requests. Cost levers for callers: deduplicate identical payloads before
+ `AddAsync`, cache `AccessAsync` results per revision (immutable → cacheable
+ indefinitely), destroy old revisions beyond a retention window — but never
+ destroy a revision still referenced as a rollback target.
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Compendium.Adapters.Scaleway.SecretManager.csproj b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Compendium.Adapters.Scaleway.SecretManager.csproj
new file mode 100644
index 0000000..b00ada2
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Compendium.Adapters.Scaleway.SecretManager.csproj
@@ -0,0 +1,32 @@
+
+
+
+ Compendium.Adapters.Scaleway.SecretManager
+ Compendium.Adapters.Scaleway.SecretManager
+ Compendium.Adapters.Scaleway.SecretManager
+ Scaleway Secret Manager adapter for Compendium Framework: a concrete ISecretVault implementing the Compendium.Abstractions.Secrets ports against the Scaleway Secret Manager regional API (v1beta1). Immutable versions addressed by revision, enable/disable kill-switch, destroy for retention control, hierarchical paths and tag filters, uniform SecretVault.* error mapping including throttling with Retry-After.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Configuration/ScalewaySecretManagerOptions.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Configuration/ScalewaySecretManagerOptions.cs
new file mode 100644
index 0000000..c4616e7
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Configuration/ScalewaySecretManagerOptions.cs
@@ -0,0 +1,34 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Configuration;
+
+///
+/// Adapter options. Deliberately credential-free: the IAM secret key travels
+/// in the
+/// passed to every call, so one adapter instance serves any number of
+/// tenancies and no secret lives in host configuration.
+///
+public sealed class ScalewaySecretManagerOptions
+{
+ ///
+ /// Gets or sets the API base URL. Override for testing against a stub.
+ ///
+ public Uri ApiBaseUrl { get; set; } = ScalewayDefaults.DefaultApiBase;
+
+ ///
+ /// Gets or sets the region used when the connection does not specify one
+ /// (e.g. "fr-par", "nl-ams", "pl-waw").
+ ///
+ public string DefaultRegion { get; set; } = "fr-par";
+
+ ///
+ /// Gets or sets the Scaleway project id used when the connection does not
+ /// specify a tenancy.
+ ///
+ public string? DefaultProjectId { get; set; }
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/DependencyInjection/ServiceCollectionExtensions.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..279e73f
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,59 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Adapters.Scaleway.SecretManager.Configuration;
+using Compendium.Adapters.Scaleway.SecretManager.Http;
+using Compendium.Adapters.Scaleway.SecretManager.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.DependencyInjection;
+
+///
+/// DI extensions for registering the Scaleway Secret Manager
+/// adapter.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers the Scaleway Secret Manager adapter. The facade is
+ /// contributed to IEnumerable<ISecretVault> for
+ /// provider-dispatch consumers and also resolvable as
+ /// directly.
+ ///
+ /// The service collection.
+ /// An optional callback to configure adapter options.
+ /// The service collection, for chaining.
+ public static IServiceCollection AddScalewaySecretVault(
+ this IServiceCollection services, Action? configure = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ if (configure is not null)
+ {
+ services.Configure(configure);
+ }
+ else
+ {
+ services.AddOptions();
+ }
+
+ services.AddHttpClient(ScalewayDefaults.HttpClientName);
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => new ScalewaySecretVault(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+ services.TryAddEnumerable(ServiceDescriptor.Singleton(
+ static sp => sp.GetRequiredService()));
+
+ return services;
+ }
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/GlobalUsings.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/GlobalUsings.cs
new file mode 100644
index 0000000..d2393ca
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/GlobalUsings.cs
@@ -0,0 +1,8 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+global using Compendium.Core.Results;
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiClient.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiClient.cs
new file mode 100644
index 0000000..e11e754
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiClient.cs
@@ -0,0 +1,167 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Adapters.Scaleway.SecretManager.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Http;
+
+///
+/// The raw HTTP executor for the Secret Manager regional API: builds
+/// authenticated requests from the per-call connection, deserializes
+/// snake_case bodies, and maps provider failures onto the uniform
+/// SecretVault.* errors. Requests are sent exactly once — write
+/// endpoints are not idempotent, so retry policy belongs to callers who can
+/// deduplicate.
+///
+internal sealed class ScalewayApiClient
+{
+ private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ScalewaySecretManagerOptions _options;
+
+ public ScalewayApiClient(IHttpClientFactory httpClientFactory, IOptions options)
+ {
+ _httpClientFactory = httpClientFactory;
+ _options = options.Value;
+ }
+
+ ///
+ /// Resolves the Scaleway project id from the connection tenancy or the
+ /// configured default.
+ ///
+ public Result ResolveProject(SecretVaultConnection connection)
+ {
+ var project = connection.Tenancy ?? _options.DefaultProjectId;
+ return string.IsNullOrWhiteSpace(project)
+ ? Result.Failure(SecretVaultErrors.NotConfigured(ScalewayDefaults.Provider))
+ : Result.Success(project);
+ }
+
+ ///
+ /// Sends a request and deserializes the successful body. notFound
+ /// and conflict supply the caller's context-specific mapping for
+ /// 404/409; every other failure maps uniformly.
+ ///
+ public async Task> SendAsync(
+ SecretVaultConnection connection,
+ HttpMethod method,
+ string path,
+ object? body,
+ Error notFound,
+ Error? conflict = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (connection.Credential is not SecretVaultCredential.ApiToken token ||
+ string.IsNullOrWhiteSpace(token.Token))
+ {
+ return Result.Failure(SecretVaultErrors.NotConfigured(ScalewayDefaults.Provider));
+ }
+
+ using var request = new HttpRequestMessage(method, BuildUri(connection, path));
+ request.Headers.Add(ScalewayDefaults.AuthHeader, token.Token);
+ if (body is not null)
+ {
+ request.Content = JsonContent.Create(body, options: Json);
+ }
+
+ HttpResponseMessage response;
+ try
+ {
+ var client = _httpClientFactory.CreateClient(ScalewayDefaults.HttpClientName);
+ response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+ {
+ return Result.Failure(SecretVaultErrors.ProviderRejected(
+ ScalewayDefaults.Provider, 0, ex.Message));
+ }
+
+ using (response)
+ {
+ if (response.IsSuccessStatusCode)
+ {
+ if (typeof(TResponse) == typeof(Unit))
+ {
+ return Result.Success((TResponse)(object)Unit.Value);
+ }
+
+ var payload = await response.Content
+ .ReadFromJsonAsync(Json, cancellationToken)
+ .ConfigureAwait(false);
+ return payload is null
+ ? Result.Failure(SecretVaultErrors.ProviderRejected(
+ ScalewayDefaults.Provider, (int)response.StatusCode, "Empty response body."))
+ : Result.Success(payload);
+ }
+
+ var error = await MapErrorAsync(response, notFound, conflict, cancellationToken).ConfigureAwait(false);
+ return Result.Failure(error);
+ }
+ }
+
+ /// A no-body marker for void endpoints.
+ internal readonly record struct Unit
+ {
+ public static Unit Value => default;
+ }
+
+ private Uri BuildUri(SecretVaultConnection connection, string path)
+ {
+ var region = connection.Region ?? _options.DefaultRegion;
+ return new Uri(_options.ApiBaseUrl, $"secret-manager/v1beta1/regions/{region}/{path}");
+ }
+
+ private static async Task MapErrorAsync(
+ HttpResponseMessage response, Error notFound, Error? conflict, CancellationToken cancellationToken)
+ {
+ var detail = await ReadErrorMessageAsync(response, cancellationToken).ConfigureAwait(false);
+ return response.StatusCode switch
+ {
+ HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden =>
+ SecretVaultErrors.AuthenticationFailed(ScalewayDefaults.Provider, detail),
+ HttpStatusCode.NotFound => notFound,
+ HttpStatusCode.Conflict when conflict is not null => conflict,
+ HttpStatusCode.TooManyRequests =>
+ SecretVaultErrors.Throttled(ScalewayDefaults.Provider, ReadRetryAfterSeconds(response)),
+ HttpStatusCode.BadRequest or HttpStatusCode.PreconditionFailed when
+ detail is not null && detail.Contains("quota", StringComparison.OrdinalIgnoreCase) =>
+ SecretVaultErrors.QuotaExceeded(ScalewayDefaults.Provider, detail),
+ _ => SecretVaultErrors.ProviderRejected(
+ ScalewayDefaults.Provider, (int)response.StatusCode, detail),
+ };
+ }
+
+ private static async Task ReadErrorMessageAsync(
+ HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var body = await response.Content
+ .ReadFromJsonAsync(Json, cancellationToken)
+ .ConfigureAwait(false);
+ return body?.Message;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static int? ReadRetryAfterSeconds(HttpResponseMessage response) =>
+ response.Headers.RetryAfter?.Delta is { } delta ? (int)delta.TotalSeconds : null;
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiModels.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiModels.cs
new file mode 100644
index 0000000..a9a05c7
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiModels.cs
@@ -0,0 +1,129 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Text.Json.Serialization;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Http;
+
+/// A Secret Manager secret resource.
+internal sealed record ScalewaySecret
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("project_id")]
+ public string? ProjectId { get; init; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("path")]
+ public string? Path { get; init; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; init; }
+
+ [JsonPropertyName("tags")]
+ public IReadOnlyList? Tags { get; init; }
+
+ [JsonPropertyName("version_count")]
+ public long? VersionCount { get; init; }
+
+ [JsonPropertyName("created_at")]
+ public DateTimeOffset? CreatedAt { get; init; }
+}
+
+/// A Secret Manager secret version resource.
+internal sealed record ScalewaySecretVersion
+{
+ [JsonPropertyName("revision")]
+ public required long Revision { get; init; }
+
+ [JsonPropertyName("secret_id")]
+ public string? SecretId { get; init; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; init; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; init; }
+
+ [JsonPropertyName("created_at")]
+ public DateTimeOffset? CreatedAt { get; init; }
+}
+
+/// The payload returned by the version access endpoint.
+internal sealed record ScalewayAccessResponse
+{
+ [JsonPropertyName("revision")]
+ public long Revision { get; init; }
+
+ [JsonPropertyName("data")]
+ public required string Data { get; init; }
+}
+
+/// A paginated secret listing.
+internal sealed record ScalewayListSecretsResponse
+{
+ [JsonPropertyName("secrets")]
+ public IReadOnlyList? Secrets { get; init; }
+
+ [JsonPropertyName("total_count")]
+ public long TotalCount { get; init; }
+}
+
+/// A paginated version listing.
+internal sealed record ScalewayListVersionsResponse
+{
+ [JsonPropertyName("versions")]
+ public IReadOnlyList? Versions { get; init; }
+
+ [JsonPropertyName("total_count")]
+ public long TotalCount { get; init; }
+}
+
+/// The request to create a secret.
+internal sealed record ScalewayCreateSecretRequest
+{
+ [JsonPropertyName("project_id")]
+ public required string ProjectId { get; init; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("path")]
+ public required string Path { get; init; }
+
+ [JsonPropertyName("description")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Description { get; init; }
+
+ [JsonPropertyName("tags")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public IReadOnlyList? Tags { get; init; }
+}
+
+/// The request to append a secret version.
+internal sealed record ScalewayCreateVersionRequest
+{
+ [JsonPropertyName("data")]
+ public required string Data { get; init; }
+
+ [JsonPropertyName("description")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Description { get; init; }
+}
+
+/// A Scaleway API error body.
+internal sealed record ScalewayErrorResponse
+{
+ [JsonPropertyName("message")]
+ public string? Message { get; init; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; init; }
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayMapping.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayMapping.cs
new file mode 100644
index 0000000..bf42b8f
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayMapping.cs
@@ -0,0 +1,97 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Model;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Http;
+
+///
+/// Pure mappings between the neutral abstraction model and the Secret Manager
+/// wire model: key/value tags to the provider's string list
+/// (key:value), paths, statuses, and resource descriptors.
+///
+internal static class ScalewayMapping
+{
+ ///
+ /// Encodes neutral key/value tags as the provider's string list.
+ ///
+ public static IReadOnlyList? ToTagList(IReadOnlyDictionary? tags) =>
+ tags is null or { Count: 0 }
+ ? null
+ : [.. tags.Select(kv => string.IsNullOrEmpty(kv.Value) ? kv.Key : $"{kv.Key}:{kv.Value}")];
+
+ ///
+ /// Decodes the provider's string list back into key/value tags (first
+ /// : splits; colon-less tags map to an empty value).
+ ///
+ public static IReadOnlyDictionary FromTagList(IReadOnlyList? tags)
+ {
+ var result = new Dictionary();
+ foreach (var tag in tags ?? [])
+ {
+ var separator = tag.IndexOf(':', StringComparison.Ordinal);
+ if (separator < 0)
+ {
+ result[tag] = string.Empty;
+ }
+ else
+ {
+ result[tag[..separator]] = tag[(separator + 1)..];
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Parses a provider path (/a/b) back into a scope path, tolerating
+ /// separators and empty segments.
+ ///
+ public static SecretScopePath ParsePath(string? path)
+ {
+ var segments = (path ?? "/").Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var parsed = SecretScopePath.From(segments);
+ return parsed.IsSuccess ? parsed.Value : SecretScopePath.Root;
+ }
+
+ ///
+ /// Maps a provider version status onto the neutral lifecycle. Unknown
+ /// statuses read as disabled (fail-closed for consumption).
+ ///
+ public static VaultSecretVersionStatus ParseStatus(string? status) =>
+ status?.ToLowerInvariant() switch
+ {
+ "enabled" => VaultSecretVersionStatus.Enabled,
+ "deleted" or "destroyed" => VaultSecretVersionStatus.Destroyed,
+ _ => VaultSecretVersionStatus.Disabled,
+ };
+
+ ///
+ /// Maps a provider secret onto the neutral descriptor.
+ ///
+ public static VaultSecretDescriptor ToDescriptor(ScalewaySecret secret) => new()
+ {
+ SecretId = secret.Id,
+ Name = secret.Name,
+ Path = ParsePath(secret.Path),
+ Description = string.IsNullOrEmpty(secret.Description) ? null : secret.Description,
+ Tags = FromTagList(secret.Tags),
+ VersionCount = secret.VersionCount,
+ CreatedAt = secret.CreatedAt,
+ };
+
+ ///
+ /// Maps a provider version onto the neutral version.
+ ///
+ public static VaultSecretVersion ToVersion(ScalewaySecretVersion version) => new()
+ {
+ Revision = version.Revision,
+ Status = ParseStatus(version.Status),
+ Description = string.IsNullOrEmpty(version.Description) ? null : version.Description,
+ CreatedAt = version.CreatedAt,
+ };
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/README.md b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/README.md
new file mode 100644
index 0000000..80fa6a6
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/README.md
@@ -0,0 +1,35 @@
+# Compendium.Adapters.Scaleway.SecretManager
+
+Scaleway Secret Manager adapter for `Compendium.Abstractions.Secrets`: a
+concrete `ISecretVault` over the regional `v1beta1` REST API (raw HTTP,
+`X-Auth-Token` auth — no provider SDK dependency).
+
+```csharp
+services.AddScalewaySecretVault(o =>
+{
+ o.DefaultRegion = "fr-par";
+ o.DefaultProjectId = "";
+});
+
+var connection = new SecretVaultConnection
+{
+ Provider = "scaleway",
+ Credential = new SecretVaultCredential.ApiToken(scwSecretKey),
+};
+
+var created = await vault.Secrets.CreateAsync(connection, new CreateVaultSecret
+{
+ Name = "db-password",
+ Path = SecretScopePath.From("myapp", "prod").Value,
+});
+var v1 = await vault.Versions.AddAsync(connection, created.Value.SecretId,
+ SecretMaterial.FromString("hunter2"));
+var read = await vault.Versions.AccessAsync(connection, created.Value.SecretId, v1.Value.Revision);
+```
+
+See `CAPABILITIES.md` for the declared capability matrix and operational
+notes (64 KiB payload limit, tag encoding, retry policy, billing levers).
+
+The adapter passes the `SecretVaultContractTests` behavioral contract from
+`Compendium.Testing` (run against a live tenancy via the integration suite;
+CI runs the same contract against `InMemorySecretVault`).
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewayDefaults.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewayDefaults.cs
new file mode 100644
index 0000000..3926e43
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewayDefaults.cs
@@ -0,0 +1,31 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+namespace Compendium.Adapters.Scaleway.SecretManager;
+
+///
+/// Shared constants for the Scaleway Secret Manager adapter: the provider
+/// discriminator, the named , the API
+/// base and the provider's per-version payload limit.
+///
+internal static class ScalewayDefaults
+{
+ /// The provider discriminator (matches ).
+ public const string Provider = "scaleway";
+
+ /// The named HttpClient used for Secret Manager REST calls.
+ public const string HttpClientName = "compendium-scaleway-secret-manager";
+
+ /// The authentication header carrying the IAM API secret key.
+ public const string AuthHeader = "X-Auth-Token";
+
+ /// The Secret Manager per-version payload limit, in bytes (64 KiB).
+ public const int MaxPayloadBytes = 64 * 1024;
+
+ /// The api.scaleway.com base URL used when no override is configured.
+ public static readonly Uri DefaultApiBase = new("https://api.scaleway.com");
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewaySecretVault.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewaySecretVault.cs
new file mode 100644
index 0000000..a4ee39c
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewaySecretVault.cs
@@ -0,0 +1,65 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Abstractions.Secrets.Services;
+using Compendium.Adapters.Scaleway.SecretManager.Services;
+
+namespace Compendium.Adapters.Scaleway.SecretManager;
+
+///
+/// The Scaleway Secret Manager : a stateless
+/// singleton facade over the regional v1beta1 API. See CAPABILITIES.md for
+/// the declared matrix and its limitations.
+///
+public sealed class ScalewaySecretVault : ISecretVault
+{
+ private readonly ScalewaySecretContainerService _secrets;
+ private readonly ScalewaySecretVersionService _versions;
+
+ internal ScalewaySecretVault(
+ ScalewaySecretContainerService secrets, ScalewaySecretVersionService versions)
+ {
+ _secrets = secrets;
+ _versions = versions;
+ }
+
+ ///
+ public string Provider => ScalewayDefaults.Provider;
+
+ ///
+ public SecretVaultCapabilities Capabilities { get; } = new()
+ {
+ Provider = ScalewayDefaults.Provider,
+ Entries = new Dictionary
+ {
+ [SecretVaultCapability.ImmutableVersions] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.VersionEnableDisable] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.VersionDestroy] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.PathHierarchy] = new(
+ SecretVaultCapabilityLevel.Full,
+ "Prefix listing is filtered adapter-side over the tenancy's secrets."),
+ [SecretVaultCapability.Tags] = new(
+ SecretVaultCapabilityLevel.Partial,
+ "Key/value tags are encoded as 'key:value' strings on the provider's tag list."),
+ [SecretVaultCapability.LargePayload] = new(
+ SecretVaultCapabilityLevel.None,
+ "Secret Manager limits a version payload to 64 KiB."),
+ [SecretVaultCapability.EphemeralSecrets] = new(
+ SecretVaultCapabilityLevel.None,
+ "Supported by the provider but not exposed through the v1 ports."),
+ [SecretVaultCapability.ServerSideRotation] = new(SecretVaultCapabilityLevel.None),
+ },
+ };
+
+ ///
+ public ISecretContainerService Secrets => _secrets;
+
+ ///
+ public ISecretVersionService Versions => _versions;
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretContainerService.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretContainerService.cs
new file mode 100644
index 0000000..ea43d08
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretContainerService.cs
@@ -0,0 +1,147 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Abstractions.Secrets.Services;
+using Compendium.Adapters.Scaleway.SecretManager.Http;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Services;
+
+///
+/// Secret container lifecycle against the Secret Manager API. Listing fetches
+/// the tenancy's secrets page by page and applies path-prefix and tag filters
+/// client-side, which keeps prefix semantics uniform across providers.
+///
+internal sealed class ScalewaySecretContainerService : ISecretContainerService
+{
+ private const int PageSize = 100;
+
+ private readonly ScalewayApiClient _client;
+
+ public ScalewaySecretContainerService(ScalewayApiClient client)
+ {
+ _client = client;
+ }
+
+ ///
+ public async Task> CreateAsync(
+ SecretVaultConnection connection, CreateVaultSecret request, CancellationToken cancellationToken = default)
+ {
+ var project = _client.ResolveProject(connection);
+ if (project.IsFailure)
+ {
+ return Result.Failure(project.Error);
+ }
+
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Post,
+ "secrets",
+ new ScalewayCreateSecretRequest
+ {
+ ProjectId = project.Value,
+ Name = request.Name,
+ Path = request.Path.ToString(),
+ Description = request.Description,
+ Tags = ScalewayMapping.ToTagList(request.Tags),
+ },
+ notFound: SecretVaultErrors.ProviderRejected(ScalewayDefaults.Provider, 404, "Project or region not found."),
+ conflict: SecretVaultErrors.ConflictExists(request.Name, request.Path.ToString()),
+ cancellationToken).ConfigureAwait(false);
+
+ return result.IsFailure
+ ? Result.Failure(result.Error)
+ : Result.Success(ScalewayMapping.ToDescriptor(result.Value));
+ }
+
+ ///
+ public async Task> GetAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Get,
+ $"secrets/{Uri.EscapeDataString(secretId)}",
+ body: null,
+ notFound: SecretVaultErrors.SecretNotFound(secretId),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return result.IsFailure
+ ? Result.Failure(result.Error)
+ : Result.Success(ScalewayMapping.ToDescriptor(result.Value));
+ }
+
+ ///
+ public async Task>> ListAsync(
+ SecretVaultConnection connection, SecretScopePath pathPrefix,
+ IReadOnlyDictionary? tagFilter = null, CancellationToken cancellationToken = default)
+ {
+ var project = _client.ResolveProject(connection);
+ if (project.IsFailure)
+ {
+ return Result.Failure>(project.Error);
+ }
+
+ var all = new List();
+ for (var page = 1; ; page++)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Get,
+ $"secrets?project_id={Uri.EscapeDataString(project.Value)}&page={page}&page_size={PageSize}",
+ body: null,
+ notFound: SecretVaultErrors.ProviderRejected(ScalewayDefaults.Provider, 404, "Project or region not found."),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (result.IsFailure)
+ {
+ return Result.Failure>(result.Error);
+ }
+
+ var batch = result.Value.Secrets ?? [];
+ all.AddRange(batch);
+ if (batch.Count < PageSize || all.Count >= result.Value.TotalCount)
+ {
+ break;
+ }
+ }
+
+ var prefix = pathPrefix.ToString();
+ var matches = all
+ .Select(ScalewayMapping.ToDescriptor)
+ .Where(d => IsUnderPrefix(d.Path.ToString(), prefix))
+ .Where(d => tagFilter is null ||
+ tagFilter.All(kv => d.Tags.TryGetValue(kv.Key, out var v) && v == kv.Value))
+ .OrderBy(d => d.Path.ToString(), StringComparer.Ordinal)
+ .ThenBy(d => d.Name, StringComparer.Ordinal)
+ .ToList();
+
+ return Result.Success>(matches);
+ }
+
+ ///
+ public async Task DeleteAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Delete,
+ $"secrets/{Uri.EscapeDataString(secretId)}",
+ body: null,
+ notFound: SecretVaultErrors.SecretNotFound(secretId),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // Deleting an already-gone secret is a success (idempotent contract).
+ return result.IsSuccess || result.Error.Code == $"{SecretVaultErrors.Prefix}.SecretNotFound"
+ ? Result.Success()
+ : Result.Failure(result.Error);
+ }
+
+ private static bool IsUnderPrefix(string path, string prefix) =>
+ prefix == "/" || path == prefix || path.StartsWith(prefix + "/", StringComparison.Ordinal);
+}
diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretVersionService.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretVersionService.cs
new file mode 100644
index 0000000..93f2794
--- /dev/null
+++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretVersionService.cs
@@ -0,0 +1,221 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Abstractions.Secrets.Services;
+using Compendium.Adapters.Scaleway.SecretManager.Http;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Services;
+
+///
+/// Immutable versions against the Secret Manager API. Access is by explicit
+/// revision only; ambiguous provider failures (a 4xx on access) are
+/// disambiguated by reading the version metadata so callers always get the
+/// precise VersionDisabled / VersionNotFound / SecretNotFound
+/// code. Enable/disable are made idempotent by treating "already in target
+/// state" as success.
+///
+internal sealed class ScalewaySecretVersionService : ISecretVersionService
+{
+ private const int PageSize = 100;
+
+ private readonly ScalewayApiClient _client;
+
+ public ScalewaySecretVersionService(ScalewayApiClient client)
+ {
+ _client = client;
+ }
+
+ ///
+ public async Task> AddAsync(
+ SecretVaultConnection connection, string secretId, SecretMaterial material,
+ string? description = null, CancellationToken cancellationToken = default)
+ {
+ if (material.Length > ScalewayDefaults.MaxPayloadBytes)
+ {
+ return Result.Failure(
+ SecretVaultErrors.PayloadTooLarge(material.Length, ScalewayDefaults.MaxPayloadBytes));
+ }
+
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Post,
+ $"secrets/{Uri.EscapeDataString(secretId)}/versions",
+ new ScalewayCreateVersionRequest
+ {
+ Data = Convert.ToBase64String(material.Data.Span),
+ Description = description,
+ },
+ notFound: SecretVaultErrors.SecretNotFound(secretId),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return result.IsFailure
+ ? Result.Failure(result.Error)
+ : Result.Success(ScalewayMapping.ToVersion(result.Value));
+ }
+
+ ///
+ public async Task> AccessAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Get,
+ $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}/access",
+ body: null,
+ notFound: SecretVaultErrors.VersionNotFound(secretId, revision),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.IsSuccess)
+ {
+ try
+ {
+ return Result.Success(SecretMaterial.FromBytes(Convert.FromBase64String(result.Value.Data)));
+ }
+ catch (FormatException)
+ {
+ return Result.Failure(SecretVaultErrors.ProviderRejected(
+ ScalewayDefaults.Provider, 200, "Version payload was not valid base64."));
+ }
+ }
+
+ // The provider refuses access to disabled versions with the same 4xx
+ // shape as a missing one; disambiguate through the version metadata.
+ if (result.Error.Type is ErrorType.NotFound or ErrorType.Failure)
+ {
+ var version = await GetVersionAsync(connection, secretId, revision, cancellationToken).ConfigureAwait(false);
+ if (version.IsSuccess && version.Value.Status == VaultSecretVersionStatus.Disabled)
+ {
+ return Result.Failure(SecretVaultErrors.VersionDisabled(secretId, revision));
+ }
+
+ if (version.IsFailure && version.Error.Code == $"{SecretVaultErrors.Prefix}.SecretNotFound")
+ {
+ return Result.Failure(version.Error);
+ }
+ }
+
+ return Result.Failure(result.Error);
+ }
+
+ ///
+ public async Task>> ListAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default)
+ {
+ var all = new List();
+ for (var page = 1; ; page++)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Get,
+ $"secrets/{Uri.EscapeDataString(secretId)}/versions?page={page}&page_size={PageSize}",
+ body: null,
+ notFound: SecretVaultErrors.SecretNotFound(secretId),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (result.IsFailure)
+ {
+ return Result.Failure>(result.Error);
+ }
+
+ var batch = result.Value.Versions ?? [];
+ all.AddRange(batch);
+ if (batch.Count < PageSize || all.Count >= result.Value.TotalCount)
+ {
+ break;
+ }
+ }
+
+ return Result.Success>(
+ [.. all.Select(ScalewayMapping.ToVersion).OrderBy(v => v.Revision)]);
+ }
+
+ ///
+ public Task EnableAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ SetStatusAsync(connection, secretId, revision, enable: true, cancellationToken);
+
+ ///
+ public Task DisableAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ SetStatusAsync(connection, secretId, revision, enable: false, cancellationToken);
+
+ ///
+ public async Task DestroyAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Delete,
+ $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}",
+ body: null,
+ notFound: SecretVaultErrors.VersionNotFound(secretId, revision),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return result.IsFailure ? Result.Failure(result.Error) : Result.Success();
+ }
+
+ private async Task SetStatusAsync(
+ SecretVaultConnection connection, string secretId, long revision, bool enable,
+ CancellationToken cancellationToken)
+ {
+ var action = enable ? "enable" : "disable";
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Post,
+ $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}/{action}",
+ body: new { },
+ notFound: SecretVaultErrors.VersionNotFound(secretId, revision),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.IsSuccess)
+ {
+ return Result.Success();
+ }
+
+ // The provider rejects a no-op transition; the contract requires
+ // idempotency, so "already in the target state" is a success.
+ var version = await GetVersionAsync(connection, secretId, revision, cancellationToken).ConfigureAwait(false);
+ var target = enable ? VaultSecretVersionStatus.Enabled : VaultSecretVersionStatus.Disabled;
+ return version.IsSuccess && version.Value.Status == target
+ ? Result.Success()
+ : Result.Failure(result.Error);
+ }
+
+ private async Task> GetVersionAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken)
+ {
+ var result = await _client.SendAsync(
+ connection,
+ HttpMethod.Get,
+ $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}",
+ body: null,
+ notFound: SecretVaultErrors.VersionNotFound(secretId, revision),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.IsFailure && result.Error.Code == $"{SecretVaultErrors.Prefix}.VersionNotFound")
+ {
+ // Distinguish "secret gone" from "version gone".
+ var secret = await _client.SendAsync(
+ connection,
+ HttpMethod.Get,
+ $"secrets/{Uri.EscapeDataString(secretId)}",
+ body: null,
+ notFound: SecretVaultErrors.SecretNotFound(secretId),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ if (secret.IsFailure)
+ {
+ return Result.Failure(secret.Error);
+ }
+ }
+
+ return result.IsFailure
+ ? Result.Failure(result.Error)
+ : Result.Success(ScalewayMapping.ToVersion(result.Value));
+ }
+}
diff --git a/src/Testing/Compendium.Testing/Compendium.Testing.csproj b/src/Testing/Compendium.Testing/Compendium.Testing.csproj
index 8e5d33b..357279a 100644
--- a/src/Testing/Compendium.Testing/Compendium.Testing.csproj
+++ b/src/Testing/Compendium.Testing/Compendium.Testing.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/Testing/Compendium.Testing/Secrets/InMemorySecretVault.cs b/src/Testing/Compendium.Testing/Secrets/InMemorySecretVault.cs
new file mode 100644
index 0000000..5245e69
--- /dev/null
+++ b/src/Testing/Compendium.Testing/Secrets/InMemorySecretVault.cs
@@ -0,0 +1,310 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using System.Collections.Concurrent;
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Abstractions.Secrets.Services;
+using Compendium.Core.Results;
+
+namespace Compendium.Testing.Secrets;
+
+///
+/// A full-fidelity in-memory for tests and dev
+/// hosts: immutable monotonic revisions, enable/disable/destroy semantics,
+/// path-prefix listing and tag filters, thread-safe, with a call log for
+/// interaction assertions. Subscribes to
+/// so the fake and real adapters stay behaviorally aligned.
+///
+public sealed class InMemorySecretVault : ISecretVault, ISecretContainerService, ISecretVersionService
+{
+ ///
+ /// The provider discriminator of the in-memory vault.
+ ///
+ public const string ProviderName = "inmemory";
+
+ private readonly ConcurrentDictionary _secrets = new();
+ private readonly ConcurrentQueue _callLog = new();
+ private readonly object _createLock = new();
+
+ ///
+ public string Provider => ProviderName;
+
+ ///
+ public SecretVaultCapabilities Capabilities { get; } = new()
+ {
+ Provider = ProviderName,
+ Entries = new Dictionary
+ {
+ [SecretVaultCapability.ImmutableVersions] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.VersionEnableDisable] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.VersionDestroy] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.PathHierarchy] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.Tags] = new(SecretVaultCapabilityLevel.Full),
+ [SecretVaultCapability.LargePayload] = new(SecretVaultCapabilityLevel.Full),
+ },
+ };
+
+ ///
+ public ISecretContainerService Secrets => this;
+
+ ///
+ public ISecretVersionService Versions => this;
+
+ ///
+ /// Gets the ordered log of port calls ("MethodName secretId") for
+ /// interaction assertions.
+ ///
+ public IReadOnlyCollection CallLog => _callLog;
+
+ ///
+ public Task> CreateAsync(
+ SecretVaultConnection connection, CreateVaultSecret request, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"CreateAsync {request.Path}/{request.Name}");
+ lock (_createLock)
+ {
+ var path = request.Path.ToString();
+ if (_secrets.Values.Any(s => s.Name == request.Name && s.Path == path))
+ {
+ return Task.FromResult(Result.Failure(
+ SecretVaultErrors.ConflictExists(request.Name, path)));
+ }
+
+ var stored = new StoredSecret
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = request.Name,
+ Path = path,
+ PathSegments = [.. request.Path.Segments],
+ Description = request.Description,
+ Tags = new Dictionary(request.Tags ?? new Dictionary()),
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ _secrets[stored.Id] = stored;
+ return Task.FromResult(Result.Success(Describe(stored)));
+ }
+ }
+
+ ///
+ public Task> GetAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"GetAsync {secretId}");
+ return Task.FromResult(_secrets.TryGetValue(secretId, out var stored)
+ ? Result.Success(Describe(stored))
+ : Result.Failure(SecretVaultErrors.SecretNotFound(secretId)));
+ }
+
+ ///
+ public Task>> ListAsync(
+ SecretVaultConnection connection, SecretScopePath pathPrefix,
+ IReadOnlyDictionary? tagFilter = null, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"ListAsync {pathPrefix}");
+ var matches = _secrets.Values
+ .Where(s => IsUnderPrefix(s.PathSegments, pathPrefix.Segments))
+ .Where(s => tagFilter is null || tagFilter.All(kv => s.Tags.TryGetValue(kv.Key, out var v) && v == kv.Value))
+ .OrderBy(s => s.Path, StringComparer.Ordinal)
+ .ThenBy(s => s.Name, StringComparer.Ordinal)
+ .Select(Describe)
+ .ToList();
+ return Task.FromResult(Result.Success>(matches));
+ }
+
+ ///
+ public Task DeleteAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"DeleteAsync {secretId}");
+ _secrets.TryRemove(secretId, out _);
+ return Task.FromResult(Result.Success());
+ }
+
+ ///
+ public Task> AddAsync(
+ SecretVaultConnection connection, string secretId, SecretMaterial material,
+ string? description = null, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"AddAsync {secretId}");
+ if (!_secrets.TryGetValue(secretId, out var stored))
+ {
+ return Task.FromResult(Result.Failure(SecretVaultErrors.SecretNotFound(secretId)));
+ }
+
+ lock (stored.Lock)
+ {
+ var version = new StoredVersion
+ {
+ Revision = stored.NextRevision++,
+ Material = material,
+ Status = VaultSecretVersionStatus.Enabled,
+ Description = description,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ stored.Versions[version.Revision] = version;
+ return Task.FromResult(Result.Success(DescribeVersion(version)));
+ }
+ }
+
+ ///
+ public Task> AccessAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"AccessAsync {secretId}#{revision}");
+ if (!_secrets.TryGetValue(secretId, out var stored))
+ {
+ return Task.FromResult(Result.Failure(SecretVaultErrors.SecretNotFound(secretId)));
+ }
+
+ lock (stored.Lock)
+ {
+ if (!stored.Versions.TryGetValue(revision, out var version) ||
+ version.Status == VaultSecretVersionStatus.Destroyed)
+ {
+ return Task.FromResult(Result.Failure(
+ SecretVaultErrors.VersionNotFound(secretId, revision)));
+ }
+
+ return Task.FromResult(version.Status == VaultSecretVersionStatus.Disabled
+ ? Result.Failure(SecretVaultErrors.VersionDisabled(secretId, revision))
+ : Result.Success(version.Material));
+ }
+ }
+
+ ///
+ public Task>> ListAsync(
+ SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default)
+ {
+ _callLog.Enqueue($"ListVersionsAsync {secretId}");
+ if (!_secrets.TryGetValue(secretId, out var stored))
+ {
+ return Task.FromResult(Result.Failure>(
+ SecretVaultErrors.SecretNotFound(secretId)));
+ }
+
+ lock (stored.Lock)
+ {
+ var versions = stored.Versions.Values
+ .OrderBy(v => v.Revision)
+ .Select(DescribeVersion)
+ .ToList();
+ return Task.FromResult(Result.Success>(versions));
+ }
+ }
+
+ ///
+ public Task EnableAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ SetStatusAsync("EnableAsync", secretId, revision, VaultSecretVersionStatus.Enabled);
+
+ ///
+ public Task DisableAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ SetStatusAsync("DisableAsync", secretId, revision, VaultSecretVersionStatus.Disabled);
+
+ ///
+ public Task DestroyAsync(
+ SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) =>
+ SetStatusAsync("DestroyAsync", secretId, revision, VaultSecretVersionStatus.Destroyed);
+
+ private static bool IsUnderPrefix(IReadOnlyList path, IReadOnlyList prefix)
+ {
+ if (prefix.Count > path.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < prefix.Count; i++)
+ {
+ if (!string.Equals(path[i], prefix[i], StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static VaultSecretDescriptor Describe(StoredSecret stored) => new()
+ {
+ SecretId = stored.Id,
+ Name = stored.Name,
+ Path = SecretScopePath.From([.. stored.PathSegments]).Value,
+ Description = stored.Description,
+ Tags = new Dictionary(stored.Tags),
+ VersionCount = stored.Versions.Count,
+ CreatedAt = stored.CreatedAt,
+ };
+
+ private static VaultSecretVersion DescribeVersion(StoredVersion version) => new()
+ {
+ Revision = version.Revision,
+ Status = version.Status,
+ Description = version.Description,
+ CreatedAt = version.CreatedAt,
+ };
+
+ private Task SetStatusAsync(string operation, string secretId, long revision, VaultSecretVersionStatus status)
+ {
+ _callLog.Enqueue($"{operation} {secretId}#{revision}");
+ if (!_secrets.TryGetValue(secretId, out var stored))
+ {
+ return Task.FromResult(Result.Failure(SecretVaultErrors.SecretNotFound(secretId)));
+ }
+
+ lock (stored.Lock)
+ {
+ if (!stored.Versions.TryGetValue(revision, out var version) ||
+ version.Status == VaultSecretVersionStatus.Destroyed)
+ {
+ return Task.FromResult(Result.Failure(SecretVaultErrors.VersionNotFound(secretId, revision)));
+ }
+
+ version.Status = status;
+ return Task.FromResult(Result.Success());
+ }
+ }
+
+ private sealed class StoredSecret
+ {
+ public required string Id { get; init; }
+
+ public required string Name { get; init; }
+
+ public required string Path { get; init; }
+
+ public required List PathSegments { get; init; }
+
+ public string? Description { get; init; }
+
+ public required Dictionary Tags { get; init; }
+
+ public DateTimeOffset CreatedAt { get; init; }
+
+ public Dictionary Versions { get; } = [];
+
+ public long NextRevision { get; set; } = 1;
+
+ public object Lock { get; } = new();
+ }
+
+ private sealed class StoredVersion
+ {
+ public required long Revision { get; init; }
+
+ public required SecretMaterial Material { get; init; }
+
+ public required VaultSecretVersionStatus Status { get; set; }
+
+ public string? Description { get; init; }
+
+ public DateTimeOffset CreatedAt { get; init; }
+ }
+}
diff --git a/src/Testing/Compendium.Testing/Secrets/SecretVaultContractTests.cs b/src/Testing/Compendium.Testing/Secrets/SecretVaultContractTests.cs
new file mode 100644
index 0000000..c9390d7
--- /dev/null
+++ b/src/Testing/Compendium.Testing/Secrets/SecretVaultContractTests.cs
@@ -0,0 +1,319 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Core.Results;
+using FluentAssertions;
+using Xunit;
+
+namespace Compendium.Testing.Secrets;
+
+///
+/// The behavioral contract every adapter must
+/// satisfy. Inherit in the adapter's test suite and provide the fixture
+/// members; tests for capabilities the adapter does not declare are skipped
+/// automatically (capability honesty is itself asserted: a declared
+/// capability must work). subscribes to
+/// this contract, keeping the fake and real adapters aligned. Passing this
+/// suite is what makes swapping vault backends safe.
+///
+public abstract class SecretVaultContractTests
+{
+ /// Gets the vault under test.
+ protected abstract ISecretVault Vault { get; }
+
+ /// Gets a connection with valid credentials for .
+ protected abstract SecretVaultConnection Connection { get; }
+
+ /// Gets a path under which the connection can create secrets.
+ protected virtual SecretScopePath TestPath => SecretScopePath.From("contract-tests").Value;
+
+ /// Generates a unique secret name for a test run.
+ protected virtual string NewSecretName() => $"contract-{Guid.NewGuid():N}"[..24];
+
+ /// The declared provider must match the facade discriminator and never be empty.
+ [Fact]
+ public void Capabilities_DeclaresTheVaultProvider()
+ {
+ Vault.Provider.Should().NotBeNullOrWhiteSpace();
+ Vault.Capabilities.Provider.Should().Be(Vault.Provider);
+ }
+
+ /// An undeclared capability must fail with the standard machine code, never throw.
+ [SkippableFact]
+ public void EnsureSupported_UndeclaredCapability_FailsWithCapabilityNotSupported()
+ {
+ var undeclared = Enum.GetValues()
+ .FirstOrDefault(c => !Vault.Capabilities.Supports(c), (SecretVaultCapability)(-1));
+ Skip.If(undeclared == (SecretVaultCapability)(-1), "Adapter declares every capability.");
+
+ var result = Vault.Capabilities.EnsureSupported(undeclared);
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.CapabilityNotSupported");
+ }
+
+ /// Creation round-trips through Get by the returned provider-side id.
+ [Fact]
+ public async Task Create_RoundTripsThroughGet()
+ {
+ var name = NewSecretName();
+ var created = await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret
+ {
+ Name = name,
+ Path = TestPath,
+ Description = "contract round-trip",
+ });
+
+ created.IsSuccess.Should().BeTrue(created.IsFailure ? created.Error.Message : string.Empty);
+ created.Value.SecretId.Should().NotBeNullOrWhiteSpace();
+ created.Value.Name.Should().Be(name);
+
+ var fetched = await Vault.Secrets.GetAsync(Connection, created.Value.SecretId);
+ fetched.IsSuccess.Should().BeTrue(fetched.IsFailure ? fetched.Error.Message : string.Empty);
+ fetched.Value.Name.Should().Be(name);
+ fetched.Value.Path.ToString().Should().Be(TestPath.ToString());
+ }
+
+ /// Creating the same (name, path) twice conflicts with the standard code.
+ [Fact]
+ public async Task Create_DuplicateNameAndPath_Conflicts()
+ {
+ var request = new CreateVaultSecret { Name = NewSecretName(), Path = TestPath };
+
+ (await Vault.Secrets.CreateAsync(Connection, request)).IsSuccess.Should().BeTrue();
+ var duplicate = await Vault.Secrets.CreateAsync(Connection, request);
+
+ duplicate.IsFailure.Should().BeTrue();
+ duplicate.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.ConflictExists");
+ }
+
+ /// Reading an unknown secret fails with the standard not-found code.
+ [Fact]
+ public async Task Get_UnknownSecret_FailsWithSecretNotFound()
+ {
+ var result = await Vault.Secrets.GetAsync(Connection, Guid.NewGuid().ToString());
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.SecretNotFound");
+ }
+
+ /// Revisions start at 1 and increase monotonically.
+ [Fact]
+ public async Task Add_RevisionsAreMonotonicFromOne()
+ {
+ var secretId = await CreateSecretAsync();
+
+ var first = await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v1"));
+ var second = await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v2"));
+
+ first.IsSuccess.Should().BeTrue(first.IsFailure ? first.Error.Message : string.Empty);
+ second.IsSuccess.Should().BeTrue(second.IsFailure ? second.Error.Message : string.Empty);
+ first.Value.Revision.Should().Be(1);
+ second.Value.Revision.Should().Be(2);
+ }
+
+ /// Writing a new version never alters an older revision's material (immutability).
+ [SkippableFact]
+ public async Task Access_OlderRevision_IsImmutableAfterNewerWrites()
+ {
+ Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.ImmutableVersions));
+ var secretId = await CreateSecretAsync();
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("original"));
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("replacement"));
+
+ var original = await Vault.Versions.AccessAsync(Connection, secretId, 1);
+ var replacement = await Vault.Versions.AccessAsync(Connection, secretId, 2);
+
+ original.IsSuccess.Should().BeTrue(original.IsFailure ? original.Error.Message : string.Empty);
+ original.Value.AsString().Should().Be("original");
+ replacement.Value.AsString().Should().Be("replacement");
+ }
+
+ /// Accessing a never-written revision fails with the standard code.
+ [Fact]
+ public async Task Access_UnknownRevision_FailsWithVersionNotFound()
+ {
+ var secretId = await CreateSecretAsync();
+
+ var result = await Vault.Versions.AccessAsync(Connection, secretId, 42);
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.VersionNotFound");
+ }
+
+ /// Accessing a version of an unknown secret fails with secret-not-found.
+ [Fact]
+ public async Task Access_UnknownSecret_FailsWithSecretNotFound()
+ {
+ var result = await Vault.Versions.AccessAsync(Connection, Guid.NewGuid().ToString(), 1);
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.SecretNotFound");
+ }
+
+ /// Version listing reports every written revision with its status and description.
+ [Fact]
+ public async Task ListVersions_ReportsAllRevisions()
+ {
+ var secretId = await CreateSecretAsync();
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v1"), "first");
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v2"), "second");
+
+ var versions = await Vault.Versions.ListAsync(Connection, secretId);
+
+ versions.IsSuccess.Should().BeTrue(versions.IsFailure ? versions.Error.Message : string.Empty);
+ versions.Value.Should().HaveCount(2);
+ versions.Value.Select(v => v.Revision).Should().ContainInOrder(1L, 2L);
+ versions.Value.Should().OnlyContain(v => v.Status == VaultSecretVersionStatus.Enabled);
+ }
+
+ /// Disable kill-switches a revision (access refused) and Enable restores it.
+ [SkippableFact]
+ public async Task DisableEnable_KillSwitchRoundTrip()
+ {
+ Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.VersionEnableDisable));
+ var secretId = await CreateSecretAsync();
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("hot"));
+
+ (await Vault.Versions.DisableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+ var whileDisabled = await Vault.Versions.AccessAsync(Connection, secretId, 1);
+ whileDisabled.IsFailure.Should().BeTrue();
+ whileDisabled.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.VersionDisabled");
+
+ (await Vault.Versions.EnableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+ var reEnabled = await Vault.Versions.AccessAsync(Connection, secretId, 1);
+ reEnabled.IsSuccess.Should().BeTrue(reEnabled.IsFailure ? reEnabled.Error.Message : string.Empty);
+ reEnabled.Value.AsString().Should().Be("hot");
+ }
+
+ /// Disable and Enable are idempotent on a revision already in the target state.
+ [SkippableFact]
+ public async Task DisableEnable_AreIdempotent()
+ {
+ Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.VersionEnableDisable));
+ var secretId = await CreateSecretAsync();
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v1"));
+
+ (await Vault.Versions.DisableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+ (await Vault.Versions.DisableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+ (await Vault.Versions.EnableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+ (await Vault.Versions.EnableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+ }
+
+ /// Destroyed material reads as not found and its revision number is never reused.
+ [SkippableFact]
+ public async Task Destroy_MaterialGoneAndRevisionNumberReserved()
+ {
+ Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.VersionDestroy));
+ var secretId = await CreateSecretAsync();
+ await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("doomed"));
+
+ (await Vault.Versions.DestroyAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue();
+
+ var access = await Vault.Versions.AccessAsync(Connection, secretId, 1);
+ access.IsFailure.Should().BeTrue();
+ access.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.VersionNotFound");
+
+ var next = await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("survivor"));
+ next.IsSuccess.Should().BeTrue(next.IsFailure ? next.Error.Message : string.Empty);
+ next.Value.Revision.Should().Be(2, "destroyed revision numbers must stay reserved");
+ }
+
+ /// Deleting a container removes it (and delete is idempotent).
+ [Fact]
+ public async Task Delete_RemovesContainer_AndIsIdempotent()
+ {
+ var secretId = await CreateSecretAsync();
+
+ (await Vault.Secrets.DeleteAsync(Connection, secretId)).IsSuccess.Should().BeTrue();
+ (await Vault.Secrets.GetAsync(Connection, secretId)).IsFailure.Should().BeTrue();
+ (await Vault.Secrets.DeleteAsync(Connection, secretId)).IsSuccess.Should().BeTrue();
+ }
+
+ /// Prefix listing finds secrets nested under the prefix, not siblings.
+ [SkippableFact]
+ public async Task List_ByPathPrefix_FindsNestedSecretsOnly()
+ {
+ Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.PathHierarchy));
+ var marker = $"nest{Guid.NewGuid():N}"[..12];
+ var nestedPath = SecretVaultContractTestsHelper.Append(TestPath, marker, "inner").Value;
+ var siblingPath = SecretVaultContractTestsHelper.Append(TestPath, $"other{Guid.NewGuid():N}"[..12]).Value;
+ var nestedName = NewSecretName();
+ await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret { Name = nestedName, Path = nestedPath });
+ await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret { Name = NewSecretName(), Path = siblingPath });
+
+ var listed = await Vault.Secrets.ListAsync(
+ Connection, SecretVaultContractTestsHelper.Append(TestPath, marker).Value);
+
+ listed.IsSuccess.Should().BeTrue(listed.IsFailure ? listed.Error.Message : string.Empty);
+ listed.Value.Should().ContainSingle(d => d.Name == nestedName);
+ }
+
+ /// Tag filters match secrets carrying every requested tag.
+ [SkippableFact]
+ public async Task List_ByTagFilter_MatchesAllRequestedTags()
+ {
+ Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.Tags));
+ var marker = Guid.NewGuid().ToString("N");
+ var taggedName = NewSecretName();
+ await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret
+ {
+ Name = taggedName,
+ Path = TestPath,
+ Tags = new Dictionary { ["contract-marker"] = marker, ["managed-by"] = "contract" },
+ });
+ await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret
+ {
+ Name = NewSecretName(),
+ Path = TestPath,
+ Tags = new Dictionary { ["managed-by"] = "contract" },
+ });
+
+ var listed = await Vault.Secrets.ListAsync(
+ Connection, TestPath, new Dictionary { ["contract-marker"] = marker });
+
+ listed.IsSuccess.Should().BeTrue(listed.IsFailure ? listed.Error.Message : string.Empty);
+ listed.Value.Should().ContainSingle(d => d.Name == taggedName);
+ }
+
+ /// Secret material and token credentials never leak through ToString().
+ [Fact]
+ public void Redaction_MaterialAndCredentialsNeverStringify()
+ {
+ SecretMaterial.FromString("hunter2").ToString().Should().NotContain("hunter2");
+ new SecretVaultCredential.ApiToken("hunter2").ToString().Should().NotContain("hunter2");
+ var connection = Connection with { Credential = new SecretVaultCredential.ApiToken("hunter2") };
+ connection.ToString().Should().NotContain("hunter2");
+ }
+
+ private async Task CreateSecretAsync()
+ {
+ var created = await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret
+ {
+ Name = NewSecretName(),
+ Path = TestPath,
+ });
+ created.IsSuccess.Should().BeTrue(created.IsFailure ? created.Error.Message : string.Empty);
+ return created.Value.SecretId;
+ }
+}
+
+///
+/// Path helpers for .
+///
+internal static class SecretVaultContractTestsHelper
+{
+ ///
+ /// Appends segments to a path.
+ ///
+ public static Result Append(SecretScopePath path, params string[] segments) =>
+ SecretScopePath.From([.. path.Segments, .. segments]);
+}
diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/Compendium.Abstractions.Secrets.Tests.csproj b/tests/Unit/Compendium.Abstractions.Secrets.Tests/Compendium.Abstractions.Secrets.Tests.csproj
new file mode 100644
index 0000000..87fa4cf
--- /dev/null
+++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/Compendium.Abstractions.Secrets.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ Compendium.Abstractions.Secrets.Tests
+ Compendium.Abstractions.Secrets.Tests
+ false
+ true
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/InMemorySecretVaultContractTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/InMemorySecretVaultContractTests.cs
new file mode 100644
index 0000000..f29581f
--- /dev/null
+++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/InMemorySecretVaultContractTests.cs
@@ -0,0 +1,31 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Testing.Secrets;
+
+namespace Compendium.Abstractions.Secrets.Tests;
+
+///
+/// Subscribes to the vault behavioral
+/// contract, keeping the fake aligned with real adapters.
+///
+public sealed class InMemorySecretVaultContractTests : SecretVaultContractTests
+{
+ private readonly InMemorySecretVault _vault = new();
+
+ ///
+ protected override ISecretVault Vault => _vault;
+
+ ///
+ protected override SecretVaultConnection Connection { get; } = new()
+ {
+ Provider = InMemorySecretVault.ProviderName,
+ Credential = new SecretVaultCredential.None(),
+ };
+}
diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/NullSecretVaultTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/NullSecretVaultTests.cs
new file mode 100644
index 0000000..24b694b
--- /dev/null
+++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/NullSecretVaultTests.cs
@@ -0,0 +1,70 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Abstractions.Secrets.Stubs;
+using FluentAssertions;
+using Xunit;
+
+namespace Compendium.Abstractions.Secrets.Tests;
+
+///
+/// The null vault must fail every operation with the standard
+/// not-configured error and declare no capabilities.
+///
+public sealed class NullSecretVaultTests
+{
+ private static readonly SecretVaultConnection Connection = new()
+ {
+ Provider = NullSecretVault.ProviderName,
+ Credential = new SecretVaultCredential.None(),
+ };
+
+ private readonly NullSecretVault _vault = new();
+
+ [Fact]
+ public void DeclaresNoCapabilities()
+ {
+ _vault.Provider.Should().Be("null");
+ _vault.Capabilities.Entries.Should().BeEmpty();
+ Enum.GetValues()
+ .Should().OnlyContain(c => !_vault.Capabilities.Supports(c));
+ }
+
+ [Fact]
+ public async Task EveryOperation_FailsWithNotConfigured()
+ {
+ var expected = $"{SecretVaultErrors.Prefix}.NotConfigured";
+ var create = await _vault.Secrets.CreateAsync(Connection, new CreateVaultSecret
+ {
+ Name = "any",
+ Path = SecretScopePath.Root,
+ });
+ var get = await _vault.Secrets.GetAsync(Connection, "id");
+ var list = await _vault.Secrets.ListAsync(Connection, SecretScopePath.Root);
+ var delete = await _vault.Secrets.DeleteAsync(Connection, "id");
+ var add = await _vault.Versions.AddAsync(Connection, "id", SecretMaterial.FromString("x"));
+ var access = await _vault.Versions.AccessAsync(Connection, "id", 1);
+ var versions = await _vault.Versions.ListAsync(Connection, "id");
+ var enable = await _vault.Versions.EnableAsync(Connection, "id", 1);
+ var disable = await _vault.Versions.DisableAsync(Connection, "id", 1);
+ var destroy = await _vault.Versions.DestroyAsync(Connection, "id", 1);
+
+ create.Error.Code.Should().Be(expected);
+ get.Error.Code.Should().Be(expected);
+ list.Error.Code.Should().Be(expected);
+ delete.Error.Code.Should().Be(expected);
+ add.Error.Code.Should().Be(expected);
+ access.Error.Code.Should().Be(expected);
+ versions.Error.Code.Should().Be(expected);
+ enable.Error.Code.Should().Be(expected);
+ disable.Error.Code.Should().Be(expected);
+ destroy.Error.Code.Should().Be(expected);
+ }
+}
diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretMaterialTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretMaterialTests.cs
new file mode 100644
index 0000000..b6f4783
--- /dev/null
+++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretMaterialTests.cs
@@ -0,0 +1,39 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Model;
+using FluentAssertions;
+using Xunit;
+
+namespace Compendium.Abstractions.Secrets.Tests;
+
+///
+/// Byte/string round-trips and copy semantics of .
+///
+public sealed class SecretMaterialTests
+{
+ [Fact]
+ public void FromBytes_CopiesTheSource_SoLaterMutationDoesNotAlterTheMaterial()
+ {
+ var source = new byte[] { 1, 2, 3 };
+ var material = SecretMaterial.FromBytes(source);
+
+ source[0] = 99;
+
+ material.Data.ToArray().Should().ContainInOrder((byte)1, (byte)2, (byte)3);
+ material.Length.Should().Be(3);
+ }
+
+ [Fact]
+ public void FromString_RoundTripsUtf8()
+ {
+ var material = SecretMaterial.FromString("héllo wörld");
+
+ material.AsString().Should().Be("héllo wörld");
+ material.Length.Should().BeGreaterThan(11, "non-ASCII chars take multiple UTF-8 bytes");
+ }
+}
diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretScopePathTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretScopePathTests.cs
new file mode 100644
index 0000000..20069d2
--- /dev/null
+++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretScopePathTests.cs
@@ -0,0 +1,68 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Model;
+using FluentAssertions;
+using Xunit;
+
+namespace Compendium.Abstractions.Secrets.Tests;
+
+///
+/// Validation and canonical-form tests for .
+///
+public sealed class SecretScopePathTests
+{
+ [Theory]
+ [InlineData("nexus")]
+ [InlineData("a1b2-c3_d4.e5")]
+ [InlineData("0e21a7ab-77b5-4d20-9a4c-000000000001")]
+ public void From_ValidSegment_Succeeds(string segment)
+ {
+ var result = SecretScopePath.From(segment);
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.ToString().Should().Be($"/{segment}");
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("a/b")]
+ [InlineData("a b")]
+ [InlineData("a\\b")]
+ public void From_InvalidSegment_FailsWithValidationError(string segment)
+ {
+ var result = SecretScopePath.From(segment);
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.InvalidPathSegment");
+ }
+
+ [Fact]
+ public void From_SegmentOver100Characters_Fails()
+ {
+ SecretScopePath.From(new string('a', 101)).IsFailure.Should().BeTrue();
+ SecretScopePath.From(new string('a', 100)).IsSuccess.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Root_IsSlash_WithNoSegments()
+ {
+ SecretScopePath.Root.Segments.Should().BeEmpty();
+ SecretScopePath.Root.ToString().Should().Be("/");
+ }
+
+ [Fact]
+ public void From_MultipleSegments_JoinsCanonically()
+ {
+ var result = SecretScopePath.From("nexus", "org-1", "app-2");
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.ToString().Should().Be("/nexus/org-1/app-2");
+ result.Value.Segments.Should().ContainInOrder("nexus", "org-1", "app-2");
+ }
+}
diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretVaultErrorsTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretVaultErrorsTests.cs
new file mode 100644
index 0000000..29b2342
--- /dev/null
+++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretVaultErrorsTests.cs
@@ -0,0 +1,124 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Core.Results;
+using FluentAssertions;
+using Xunit;
+
+namespace Compendium.Abstractions.Secrets.Tests;
+
+///
+/// Error-code and metadata guarantees consumers rely on for uniform handling.
+///
+public sealed class SecretVaultErrorsTests
+{
+ [Fact]
+ public void NotSupported_CarriesProviderAndCapabilityMetadata()
+ {
+ var error = SecretVaultErrors.NotSupported("scaleway", SecretVaultCapability.LargePayload, "64 KiB limit");
+
+ error.Code.Should().Be("SecretVault.CapabilityNotSupported");
+ error.Metadata.Should().ContainKey("provider").WhoseValue.Should().Be("scaleway");
+ error.Metadata.Should().ContainKey("capability").WhoseValue.Should().Be("LargePayload");
+ error.Metadata.Should().ContainKey("limitation");
+ }
+
+ [Fact]
+ public void Throttled_IsTooManyRequests_WithRetryAfterMetadata()
+ {
+ var error = SecretVaultErrors.Throttled("scaleway", 30);
+
+ error.Type.Should().Be(ErrorType.TooManyRequests);
+ error.Metadata.Should().ContainKey("retryAfterSeconds").WhoseValue.Should().Be(30);
+ }
+
+ [Fact]
+ public void PayloadTooLarge_CarriesSizes()
+ {
+ var error = SecretVaultErrors.PayloadTooLarge(100_000, 65_536);
+
+ error.Type.Should().Be(ErrorType.Validation);
+ error.Metadata.Should().ContainKey("size").WhoseValue.Should().Be(100_000);
+ error.Metadata.Should().ContainKey("maxSize").WhoseValue.Should().Be(65_536);
+ }
+
+ [Fact]
+ public void NotSupported_WithoutLimitation_OmitsTheMetadataKey()
+ {
+ var error = SecretVaultErrors.NotSupported("scaleway", SecretVaultCapability.ServerSideRotation);
+
+ error.Code.Should().Be("SecretVault.CapabilityNotSupported");
+ error.Metadata.Should().NotContainKey("limitation");
+ }
+
+ [Fact]
+ public void QuotaExceeded_IsUnavailable_WithDetailInMessage()
+ {
+ var error = SecretVaultErrors.QuotaExceeded("scaleway", "1000 secrets per project");
+
+ error.Type.Should().Be(ErrorType.Unavailable);
+ error.Message.Should().Contain("1000 secrets per project");
+ }
+
+ [Fact]
+ public void ProviderRejected_BothDetailBranches_CarryStatusCode()
+ {
+ var bare = SecretVaultErrors.ProviderRejected("scaleway", 500);
+ var detailed = SecretVaultErrors.ProviderRejected("scaleway", 502, "bad gateway");
+
+ bare.Metadata.Should().ContainKey("statusCode").WhoseValue.Should().Be(500);
+ detailed.Message.Should().Contain("bad gateway");
+ }
+
+ [Fact]
+ public void AuthenticationFailed_And_NotConfigured_BothBranches()
+ {
+ SecretVaultErrors.AuthenticationFailed("scaleway").Type.Should().Be(ErrorType.Unauthorized);
+ SecretVaultErrors.AuthenticationFailed("scaleway", "expired").Message.Should().Contain("expired");
+ SecretVaultErrors.NotConfigured().Message.Should().Contain("No secret vault");
+ SecretVaultErrors.NotConfigured("null").Message.Should().Contain("'null'");
+ }
+
+ [Fact]
+ public void Throttled_WithoutRetryAfter_OmitsTheMetadataKey()
+ {
+ var error = SecretVaultErrors.Throttled("scaleway");
+
+ error.Type.Should().Be(ErrorType.TooManyRequests);
+ error.Metadata.Should().NotContainKey("retryAfterSeconds");
+ }
+
+ [Fact]
+ public void NotFoundAndConflictFamilies_UseStableCodes()
+ {
+ SecretVaultErrors.SecretNotFound("s1").Code.Should().Be("SecretVault.SecretNotFound");
+ SecretVaultErrors.VersionNotFound("s1", 3).Code.Should().Be("SecretVault.VersionNotFound");
+ SecretVaultErrors.VersionDisabled("s1", 3).Code.Should().Be("SecretVault.VersionDisabled");
+ SecretVaultErrors.ConflictExists("name", "/p").Code.Should().Be("SecretVault.ConflictExists");
+ }
+
+ [Fact]
+ public void EnsureSupported_DeclaredCapability_Succeeds()
+ {
+ var capabilities = new SecretVaultCapabilities
+ {
+ Provider = "test",
+ Entries = new Dictionary
+ {
+ [SecretVaultCapability.Tags] = new(SecretVaultCapabilityLevel.Partial, "limited filters"),
+ },
+ };
+
+ capabilities.EnsureSupported(SecretVaultCapability.Tags).IsSuccess.Should().BeTrue();
+ capabilities.Supports(SecretVaultCapability.PathHierarchy).Should().BeFalse();
+
+ var missing = capabilities.EnsureSupported(SecretVaultCapability.PathHierarchy);
+ missing.IsFailure.Should().BeTrue();
+ missing.Error.Code.Should().Be("SecretVault.CapabilityNotSupported");
+ }
+}
diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Compendium.Adapters.Scaleway.SecretManager.Tests.csproj b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Compendium.Adapters.Scaleway.SecretManager.Tests.csproj
new file mode 100644
index 0000000..f4c783c
--- /dev/null
+++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Compendium.Adapters.Scaleway.SecretManager.Tests.csproj
@@ -0,0 +1,35 @@
+
+
+
+ Compendium.Adapters.Scaleway.SecretManager.Tests
+ Compendium.Adapters.Scaleway.SecretManager.Tests
+ false
+ true
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Infrastructure/ScalewayTestHarness.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Infrastructure/ScalewayTestHarness.cs
new file mode 100644
index 0000000..31bb40f
--- /dev/null
+++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Infrastructure/ScalewayTestHarness.cs
@@ -0,0 +1,79 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Adapters.Scaleway.SecretManager.Configuration;
+using Compendium.Adapters.Scaleway.SecretManager.Http;
+using Compendium.Adapters.Scaleway.SecretManager.Services;
+using Microsoft.Extensions.Options;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Tests.Infrastructure;
+
+///
+/// A WireMock-backed harness wiring the Scaleway adapter against a local mock
+/// Secret Manager API. Disposable — stops the WireMock server.
+///
+internal sealed class ScalewayTestHarness : IDisposable
+{
+ public const string ProjectId = "11111111-2222-3333-4444-555555555555";
+
+ public ScalewayTestHarness(string? defaultProjectId = ProjectId)
+ {
+ Server = WireMockServer.Start();
+ var options = new ScalewaySecretManagerOptions
+ {
+ ApiBaseUrl = new Uri(Server.Url!),
+ DefaultRegion = "fr-par",
+ DefaultProjectId = defaultProjectId,
+ };
+ Client = new ScalewayApiClient(new TestHttpClientFactory(), Options.Create(options));
+ Containers = new ScalewaySecretContainerService(Client);
+ Versions = new ScalewaySecretVersionService(Client);
+ Vault = new ScalewaySecretVault(Containers, Versions);
+ }
+
+ public WireMockServer Server { get; }
+
+ public ScalewayApiClient Client { get; }
+
+ public ScalewaySecretContainerService Containers { get; }
+
+ public ScalewaySecretVersionService Versions { get; }
+
+ public ScalewaySecretVault Vault { get; }
+
+ /// The regional path prefix every endpoint lives under.
+ public static string Api(string suffix) => $"/secret-manager/v1beta1/regions/fr-par/{suffix}";
+
+ /// A connection carrying a valid API token.
+ public SecretVaultConnection Connection(string token = "scw_secret_key") => new()
+ {
+ Provider = "scaleway",
+ Credential = new SecretVaultCredential.ApiToken(token),
+ };
+
+ public void Dispose() => Server.Stop();
+
+ private sealed class TestHttpClientFactory : IHttpClientFactory
+ {
+ public HttpClient CreateClient(string name) => new();
+ }
+}
+
+///
+/// WireMock JSON response builders that set Content-Type: application/json.
+///
+internal static class Json
+{
+ public static IResponseBuilder Ok(object body) => Status(200, body);
+
+ public static IResponseBuilder Status(int statusCode, object body) =>
+ Response.Create().WithStatusCode(statusCode)
+ .WithHeader("Content-Type", "application/json").WithBodyAsJson(body);
+}
diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayAdapterWiringTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayAdapterWiringTests.cs
new file mode 100644
index 0000000..3601d7c
--- /dev/null
+++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayAdapterWiringTests.cs
@@ -0,0 +1,60 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Capabilities;
+using Compendium.Adapters.Scaleway.SecretManager.DependencyInjection;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Tests;
+
+///
+/// DI registration and declared capability matrix of the Scaleway adapter.
+///
+public sealed class ScalewayAdapterWiringTests
+{
+ [Fact]
+ public void AddScalewaySecretVault_ContributesTheFacade_AndIsIdempotent()
+ {
+ var services = new ServiceCollection();
+ services.AddScalewaySecretVault(o => o.DefaultRegion = "nl-ams");
+ services.AddScalewaySecretVault();
+
+ using var provider = services.BuildServiceProvider();
+ var vaults = provider.GetServices().ToList();
+
+ vaults.Should().ContainSingle(v => v.Provider == "scaleway");
+ provider.GetRequiredService().Should().BeSameAs(vaults.Single());
+ }
+
+ [Fact]
+ public void Capabilities_DeclareTheDocumentedMatrix()
+ {
+ var services = new ServiceCollection();
+ services.AddScalewaySecretVault();
+ using var provider = services.BuildServiceProvider();
+ var vault = provider.GetRequiredService();
+
+ vault.Capabilities.Provider.Should().Be("scaleway");
+ vault.Capabilities.Supports(SecretVaultCapability.ImmutableVersions).Should().BeTrue();
+ vault.Capabilities.Supports(SecretVaultCapability.VersionEnableDisable).Should().BeTrue();
+ vault.Capabilities.Supports(SecretVaultCapability.VersionDestroy).Should().BeTrue();
+ vault.Capabilities.Supports(SecretVaultCapability.PathHierarchy).Should().BeTrue();
+ vault.Capabilities.Supports(SecretVaultCapability.Tags).Should().BeTrue();
+ vault.Capabilities.Supports(SecretVaultCapability.LargePayload).Should().BeFalse();
+ vault.Capabilities.Supports(SecretVaultCapability.ServerSideRotation).Should().BeFalse();
+
+ var refused = vault.Capabilities.EnsureSupported(SecretVaultCapability.LargePayload);
+ refused.IsFailure.Should().BeTrue();
+ refused.Error.Metadata.Should().ContainKey("limitation");
+
+ vault.Secrets.Should().NotBeNull();
+ vault.Versions.Should().NotBeNull();
+ }
+}
diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayMappingTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayMappingTests.cs
new file mode 100644
index 0000000..f76548f
--- /dev/null
+++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayMappingTests.cs
@@ -0,0 +1,112 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Adapters.Scaleway.SecretManager.Http;
+using FluentAssertions;
+using Xunit;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Tests;
+
+///
+/// Pure-mapping guarantees: tag encoding round-trips, defensive path/status
+/// parsing, descriptor/version projection.
+///
+public sealed class ScalewayMappingTests
+{
+ [Fact]
+ public void Tags_RoundTrip_IncludingEmptyValueAndColonlessForms()
+ {
+ var encoded = ScalewayMapping.ToTagList(new Dictionary
+ {
+ ["org"] = "acme",
+ ["flag"] = string.Empty,
+ });
+
+ encoded.Should().Contain("org:acme");
+ encoded.Should().Contain("flag");
+
+ var decoded = ScalewayMapping.FromTagList(["org:acme", "bare-tag", "kv:with:colons"]);
+ decoded["org"].Should().Be("acme");
+ decoded["bare-tag"].Should().Be(string.Empty);
+ decoded["kv"].Should().Be("with:colons");
+ }
+
+ [Fact]
+ public void ToTagList_EmptyOrNull_ReturnsNull()
+ {
+ ScalewayMapping.ToTagList(null).Should().BeNull();
+ ScalewayMapping.ToTagList(new Dictionary()).Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData(null, "/")]
+ [InlineData("/", "/")]
+ [InlineData("/a/b", "/a/b")]
+ [InlineData("a//b/", "/a/b")]
+ public void ParsePath_Tolerant_ProducesCanonicalForm(string? input, string expected)
+ {
+ ScalewayMapping.ParsePath(input).ToString().Should().Be(expected);
+ }
+
+ [Fact]
+ public void ParsePath_InvalidSegments_FallsBackToRoot()
+ {
+ ScalewayMapping.ParsePath("/bad segment/x").ToString().Should().Be("/");
+ }
+
+ [Theory]
+ [InlineData("enabled", VaultSecretVersionStatus.Enabled)]
+ [InlineData("ENABLED", VaultSecretVersionStatus.Enabled)]
+ [InlineData("deleted", VaultSecretVersionStatus.Destroyed)]
+ [InlineData("destroyed", VaultSecretVersionStatus.Destroyed)]
+ [InlineData("disabled", VaultSecretVersionStatus.Disabled)]
+ [InlineData("something_new", VaultSecretVersionStatus.Disabled)]
+ [InlineData(null, VaultSecretVersionStatus.Disabled)]
+ public void ParseStatus_MapsKnownStatuses_AndFailsClosedOnUnknown(string? status, VaultSecretVersionStatus expected)
+ {
+ ScalewayMapping.ParseStatus(status).Should().Be(expected);
+ }
+
+ [Fact]
+ public void ToDescriptor_ProjectsAllFields()
+ {
+ var descriptor = ScalewayMapping.ToDescriptor(new ScalewaySecret
+ {
+ Id = "sec-1",
+ ProjectId = "proj-1",
+ Name = "db",
+ Path = "/nexus/org",
+ Description = "database password",
+ Tags = ["org:acme"],
+ VersionCount = 4,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+
+ descriptor.SecretId.Should().Be("sec-1");
+ descriptor.Description.Should().Be("database password");
+ descriptor.VersionCount.Should().Be(4);
+ descriptor.CreatedAt.Should().Be(DateTimeOffset.UnixEpoch);
+ }
+
+ [Fact]
+ public void ToVersion_ProjectsAllFields_AndEmptyDescriptionReadsAsNull()
+ {
+ var version = ScalewayMapping.ToVersion(new ScalewaySecretVersion
+ {
+ Revision = 7,
+ SecretId = "sec-1",
+ Status = "enabled",
+ Description = string.Empty,
+ CreatedAt = DateTimeOffset.UnixEpoch,
+ });
+
+ version.Revision.Should().Be(7);
+ version.Description.Should().BeNull();
+ version.Status.Should().Be(VaultSecretVersionStatus.Enabled);
+ }
+}
diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretContainerServiceTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretContainerServiceTests.cs
new file mode 100644
index 0000000..478f93d
--- /dev/null
+++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretContainerServiceTests.cs
@@ -0,0 +1,244 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
+// See LICENSE in the project root for license information.
+//
+// -----------------------------------------------------------------------
+
+using Compendium.Abstractions.Secrets;
+using Compendium.Abstractions.Secrets.Connections;
+using Compendium.Abstractions.Secrets.Model;
+using Compendium.Adapters.Scaleway.SecretManager.Tests.Infrastructure;
+using FluentAssertions;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using Xunit;
+
+namespace Compendium.Adapters.Scaleway.SecretManager.Tests;
+
+public sealed class ScalewaySecretContainerServiceTests
+{
+ private static readonly SecretScopePath Path = SecretScopePath.From("nexus", "org-1").Value;
+
+ [Fact]
+ public async Task Create_SendsAuthAndProject_AndMapsDescriptor()
+ {
+ using var harness = new ScalewayTestHarness();
+ harness.Server
+ .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingPost()
+ .WithHeader("X-Auth-Token", "scw_secret_key")
+ .WithBody(b => b!.Contains(ScalewayTestHarness.ProjectId) && b.Contains("\"/nexus/org-1\"")))
+ .RespondWith(Json.Ok(new
+ {
+ id = "sec-1",
+ name = "db-password",
+ path = "/nexus/org-1",
+ tags = new[] { "org:acme", "managed-by:nexus" },
+ version_count = 0,
+ }));
+
+ var result = await harness.Containers.CreateAsync(harness.Connection(), new CreateVaultSecret
+ {
+ Name = "db-password",
+ Path = Path,
+ Tags = new Dictionary { ["org"] = "acme", ["managed-by"] = "nexus" },
+ });
+
+ result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty);
+ result.Value.SecretId.Should().Be("sec-1");
+ result.Value.Path.ToString().Should().Be("/nexus/org-1");
+ result.Value.Tags.Should().Contain("org", "acme");
+ }
+
+ [Fact]
+ public async Task Create_Conflict_MapsToConflictExists()
+ {
+ using var harness = new ScalewayTestHarness();
+ harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingPost())
+ .RespondWith(Json.Status(409, new { message = "secret already exists" }));
+
+ var result = await harness.Containers.CreateAsync(harness.Connection(), new CreateVaultSecret
+ {
+ Name = "dup",
+ Path = Path,
+ });
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be("SecretVault.ConflictExists");
+ }
+
+ [Fact]
+ public async Task Get_NotFound_MapsToSecretNotFound()
+ {
+ using var harness = new ScalewayTestHarness();
+ harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/ghost")).UsingGet())
+ .RespondWith(Json.Status(404, new { message = "not found" }));
+
+ var result = await harness.Containers.GetAsync(harness.Connection(), "ghost");
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be("SecretVault.SecretNotFound");
+ }
+
+ [Fact]
+ public async Task AnyCall_Unauthorized_MapsToAuthenticationFailed()
+ {
+ using var harness = new ScalewayTestHarness();
+ harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1")).UsingGet())
+ .RespondWith(Json.Status(403, new { message = "invalid token" }));
+
+ var result = await harness.Containers.GetAsync(harness.Connection("bad"), "sec-1");
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be("SecretVault.AuthenticationFailed");
+ }
+
+ [Fact]
+ public async Task AnyCall_Throttled_CarriesRetryAfter()
+ {
+ using var harness = new ScalewayTestHarness();
+ harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1")).UsingGet())
+ .RespondWith(Response.Create().WithStatusCode(429)
+ .WithHeader("Retry-After", "30")
+ .WithHeader("Content-Type", "application/json")
+ .WithBodyAsJson(new { message = "too many requests" }));
+
+ var result = await harness.Containers.GetAsync(harness.Connection(), "sec-1");
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be("SecretVault.Throttled");
+ result.Error.Metadata.Should().ContainKey("retryAfterSeconds").WhoseValue.Should().Be(30);
+ }
+
+ [Fact]
+ public async Task MissingCredential_FailsWithNotConfigured_WithoutNetworkCall()
+ {
+ using var harness = new ScalewayTestHarness();
+ var connection = harness.Connection() with { Credential = new SecretVaultCredential.None() };
+
+ var result = await harness.Containers.GetAsync(connection, "sec-1");
+
+ result.IsFailure.Should().BeTrue();
+ result.Error.Code.Should().Be("SecretVault.NotConfigured");
+ harness.Server.LogEntries.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task List_PaginatesAndFiltersByPrefixAndTags()
+ {
+ using var harness = new ScalewayTestHarness();
+ var pageOne = Enumerable.Range(1, 100).Select(i => new
+ {
+ id = $"sec-{i}",
+ name = $"key-{i}",
+ path = "/nexus/other",
+ tags = Array.Empty(),
+ }).Cast