Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Feat: define Redis governance serialization strategy (#33)
  • Loading branch information
rian-be committed Jun 25, 2026
commit 603338a5e6183742c2f309f0ec55dca10df45d75
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# ADR-031: Governance Redis Serialization and Document Compatibility

## Tag
#adr_031

## Status
Accepted

## Date
2026-06-25

## Scope
ModularityKit.Mutator.Governance.Redis

## Context

The Redis provider persists governed mutation requests as serialized documents.

That creates a compatibility boundary:

- request data must round-trip without losing governance semantics
- read models must tolerate heterogeneous metadata values
- provider internals must avoid a Redis-specific domain model fork
- future package versions should evolve the serialized shape deliberately, not accidentally

The governance request model already contains nested structures such as:

- mutation intent
- request metadata
- approval requirements
- decision history
- version resolution state

Without an explicit serialization decision, the provider could drift into:

- inconsistent JSON shapes across components
- fragile metadata handling for object-valued entries
- hidden coupling between runtime classes and Redis payload format

## Decision

The Redis provider serializes the existing governance request model directly as JSON documents and treats that JSON shape as the persisted document contract for the provider.

The provider should:

- serialize full `MutationRequest` documents rather than introduce a parallel storage DTO graph
- keep provider serialization centralized in dedicated Redis serialization components
- support heterogeneous metadata values through explicit converter handling
- keep document materialization inside provider internals, not spread across query code paths
- evolve document shape through deliberate package changes backed by ADRs when compatibility semantics change

## Design Rationale

- Reusing the governance model avoids translation layers that would duplicate request, approval, and decision semantics.
- Centralized serialization keeps Redis persistence mechanics consistent across reads and writes.
- Explicit converter support is necessary because governance metadata is intentionally flexible and may contain inferred object values.
- A single persisted document contract makes provider behavior easier to reason about in tests, examples, and future migrations.

## Consequences

### Positive

- Redis persistence stays aligned with governance runtime semantics.
- Serialization behavior is easier to test because all provider paths use the same document contract.
- Metadata and nested governance structures can round-trip without ad hoc per-query parsing.
- Future compatibility changes now have an explicit decision boundary.

### Negative

- The provider is coupled to the serialized shape of the governance request model.
- Backward-compatible evolution requires discipline when changing serialized request members.
- JSON document size grows with request history and approval detail.

## Related ADRs

- ADR-022: Governance Request Decisions and Storage
- ADR-029: Governance Redis Provider Package
- ADR-030: Governance Redis Request Storage and Query Strategy
4 changes: 4 additions & 0 deletions Docs/Decision/listadr.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,9 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i
| ADR-026 | Governance Request Query API | [ADR-026](Adr/ADR_026_Governance_Request_Query_API.md) |
| ADR-027 | Governed Execution Manager | [ADR-027](Adr/ADR_027_Governed_Execution_Manager.md) |
| ADR-028 | Governance Approval Workflow Hardening | [ADR-028](Adr/ADR_028_Governance_Approval_Workflow_Hardening.md) |
| ADR-029 | Governance Redis Provider Package | [ADR-029](Adr/ADR_029_Governance_Redis_Provider_Package.md) |
| ADR-030 | Governance Redis Request Storage and Query Strategy | [ADR-030](Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md) |
| ADR-031 | Governance Redis Serialization and Document Compatibility | [ADR-031](Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md) |
| ADR-032 | Governance Redis Concurrency and Index Maintenance Model | [ADR-032](Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md) |

> See individual ADRs for detailed context, decision rationale, and consequences.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using ModularityKit.Mutator.Abstractions.Context;
using ModularityKit.Mutator.Abstractions.Intent;
using ModularityKit.Mutator.Abstractions.Policies;
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
using ModularityKit.Mutator.Governance.Redis.Serialization;
using Xunit;

namespace ModularityKit.Mutator.Governance.Redis.Tests.Serialization;

public sealed class RedisMutationRequestSerializerTests
{
[Fact]
public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
{
var request = MutationRequestFactory.PendingApproval(
stateId: "tenant-42:roles",
stateType: "IamRoleState",
mutationType: "GrantRoleMutation",
intent: new MutationIntent
{
OperationName = "GrantRole",
Category = "Security",
Description = "Grant elevated access",
Tags = new HashSet<string> { "security", "urgent" },
EstimatedBlastRadius = BlastRadius.Module,
Metadata = new Dictionary<string, object>
{
["risk-owner"] = "platform"
}
},
context: MutationContext.User("requester-1", "Requester One", "Need emergency access") with
{
StateId = "tenant-42:roles",
Metadata = new Dictionary<string, object>
{
["source"] = "tests"
}
},
requirements:
[
new PolicyRequirement
{
Type = "Approval",
Description = "Requires security approval",
Data = new Dictionary<string, object>
{
["Approver"] = "security-lead",
["Reason"] = "Elevated role",
["StepOrder"] = 1L,
["RequiredApprovals"] = 1L
}
}
],
expectedStateVersion: "v10",
metadata: new Dictionary<string, object>
{
["team"] = "security",
["priority"] = "high"
})
with
{
CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero)
};

var json = RedisMutationRequestSerializer.Serialize(request);
var roundtrip = RedisMutationRequestSerializer.Deserialize(json);

Assert.Equal(request.RequestId, roundtrip.RequestId);
Assert.Equal(request.Status, roundtrip.Status);
Assert.Equal(request.PendingReason, roundtrip.PendingReason);
Assert.Equal(request.Intent.Category, roundtrip.Intent.Category);
Assert.Contains("security", roundtrip.Intent.Tags);
Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope);
Assert.Equal("security", roundtrip.Metadata["team"]);
Assert.Single(roundtrip.Requirements);
Assert.Single(roundtrip.ApprovalRequirements);
Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId);
Assert.Equal(3, roundtrip.Decisions.Count);
}
}
97 changes: 97 additions & 0 deletions src/Redis/Serialization/Converters/InferredObjectJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace ModularityKit.Mutator.Governance.Redis.Serialization.Converters;

/// <summary>
/// Deserializes flexible metadata values into inferred CLR object graphs.
/// </summary>
internal sealed class InferredObjectJsonConverter : JsonConverter<object?>
{
/// <summary>
/// Reads a JSON value into an inferred CLR object graph.
/// </summary>
/// <param name="reader">The JSON reader.</param>
/// <param name="typeToConvert">The target type.</param>
/// <param name="options">The serializer options.</param>
/// <returns>The inferred CLR value.</returns>
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> ReadValue(ref reader);

/// <summary>
/// Writes an inferred CLR value back to JSON.
/// </summary>
/// <param name="writer">The JSON writer.</param>
/// <param name="value">The CLR value to write.</param>
/// <param name="options">The serializer options.</param>
public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}

JsonSerializer.Serialize(writer, value, value.GetType(), options);
}

private static object? ReadValue(ref Utf8JsonReader reader)
{
switch (reader.TokenType)
{
case JsonTokenType.True:
return true;
case JsonTokenType.False:
return false;
case JsonTokenType.Null:
return null;
case JsonTokenType.Number:
if (reader.TryGetInt64(out var longValue))
return longValue;

if (reader.TryGetDecimal(out var decimalValue))
return decimalValue;

return reader.GetDouble();
case JsonTokenType.String:
return reader.GetString();
case JsonTokenType.StartArray:
{
var list = new List<object?>();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
return list;

list.Add(ReadValue(ref reader));
}

throw new JsonException("Unexpected end of JSON while reading array.");
}
case JsonTokenType.StartObject:
{
var dictionary = new Dictionary<string, object?>(StringComparer.Ordinal);

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return dictionary;

if (reader.TokenType != JsonTokenType.PropertyName)
throw new JsonException($"Unexpected token '{reader.TokenType}' while reading object.");

var propertyName = reader.GetString() ?? string.Empty;

if (!reader.Read())
throw new JsonException("Unexpected end of JSON after property name.");

dictionary[propertyName] = ReadValue(ref reader);
}

throw new JsonException("Unexpected end of JSON while reading object.");
}
default:
throw new JsonException($"Unsupported JSON token '{reader.TokenType}' for inferred object conversion.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace ModularityKit.Mutator.Governance.Redis.Serialization.Converters;

/// <summary>
/// Creates converters for <see cref="IReadOnlySet{T}" /> payload members.
/// </summary>
internal sealed class ReadOnlySetJsonConverterFactory : JsonConverterFactory
{
/// <summary>
/// Determines whether the supplied type is a supported read-only set type.
/// </summary>
/// <param name="typeToConvert">The type to inspect.</param>
/// <returns><see langword="true" /> when the type is supported; otherwise <see langword="false" />.</returns>
public override bool CanConvert(Type typeToConvert)
=> typeToConvert.IsGenericType &&
typeToConvert.GetGenericTypeDefinition() == typeof(IReadOnlySet<>);

/// <summary>
/// Creates a converter instance for the supplied read-only set type.
/// </summary>
/// <param name="typeToConvert">The set type to convert.</param>
/// <param name="options">The serializer options.</param>
/// <returns>The created JSON converter.</returns>
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var itemType = typeToConvert.GetGenericArguments()[0];
var converterType = typeof(ReadOnlySetJsonConverter<>).MakeGenericType(itemType);
return (JsonConverter)Activator.CreateInstance(converterType)!;
}

/// <summary>
/// Converts a concrete read-only set payload for a specific item type.
/// </summary>
/// <typeparam name="T">The item type contained in the set.</typeparam>
private sealed class ReadOnlySetJsonConverter<T> : JsonConverter<IReadOnlySet<T>>
{
/// <summary>
/// Reads a JSON array into a read-only set.
/// </summary>
/// <param name="reader">The JSON reader.</param>
/// <param name="typeToConvert">The target type.</param>
/// <param name="options">The serializer options.</param>
/// <returns>The materialized read-only set.</returns>
public override IReadOnlySet<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var values = JsonSerializer.Deserialize<HashSet<T>>(ref reader, options);
return values ?? new HashSet<T>();
}

/// <summary>
/// Writes a read-only set as a JSON array.
/// </summary>
/// <param name="writer">The JSON writer.</param>
/// <param name="value">The set value to write.</param>
/// <param name="options">The serializer options.</param>
public override void Write(Utf8JsonWriter writer, IReadOnlySet<T> value, JsonSerializerOptions options)
=> JsonSerializer.Serialize(writer, value.ToArray(), options);
}
}
52 changes: 52 additions & 0 deletions src/Redis/Serialization/RedisMutationRequestSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text.Json;
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
using ModularityKit.Mutator.Governance.Redis.Serialization.Converters;

namespace ModularityKit.Mutator.Governance.Redis.Serialization;

/// <summary>
/// Serializes governed mutation requests to and from Redis JSON payloads.
/// </summary>
internal static class RedisMutationRequestSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions();

/// <summary>
/// Serializes a governed mutation request into a Redis JSON payload.
/// </summary>
/// <param name="request">The request to serialize.</param>
/// <returns>The serialized JSON payload.</returns>
public static string Serialize(MutationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return JsonSerializer.Serialize(request, SerializerOptions);
}

/// <summary>
/// Deserializes a Redis JSON payload into a governed mutation request.
/// </summary>
/// <param name="json">The JSON payload to deserialize.</param>
/// <returns>The deserialized mutation request.</returns>
public static MutationRequest Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);

var request = JsonSerializer.Deserialize<MutationRequest>(json, SerializerOptions);
if (request is null)
throw new InvalidOperationException("Redis mutation request payload deserialized to null.");

return request;
}

private static JsonSerializerOptions CreateSerializerOptions()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

options.Converters.Add(new InferredObjectJsonConverter());
options.Converters.Add(new ReadOnlySetJsonConverterFactory());
return options;
}
}