Skip to content

Commit 603338a

Browse files
committed
Feat: define Redis governance serialization strategy (#33)
1 parent 41b5913 commit 603338a

6 files changed

Lines changed: 374 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# ADR-031: Governance Redis Serialization and Document Compatibility
2+
3+
## Tag
4+
#adr_031
5+
6+
## Status
7+
Accepted
8+
9+
## Date
10+
2026-06-25
11+
12+
## Scope
13+
ModularityKit.Mutator.Governance.Redis
14+
15+
## Context
16+
17+
The Redis provider persists governed mutation requests as serialized documents.
18+
19+
That creates a compatibility boundary:
20+
21+
- request data must round-trip without losing governance semantics
22+
- read models must tolerate heterogeneous metadata values
23+
- provider internals must avoid a Redis-specific domain model fork
24+
- future package versions should evolve the serialized shape deliberately, not accidentally
25+
26+
The governance request model already contains nested structures such as:
27+
28+
- mutation intent
29+
- request metadata
30+
- approval requirements
31+
- decision history
32+
- version resolution state
33+
34+
Without an explicit serialization decision, the provider could drift into:
35+
36+
- inconsistent JSON shapes across components
37+
- fragile metadata handling for object-valued entries
38+
- hidden coupling between runtime classes and Redis payload format
39+
40+
## Decision
41+
42+
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.
43+
44+
The provider should:
45+
46+
- serialize full `MutationRequest` documents rather than introduce a parallel storage DTO graph
47+
- keep provider serialization centralized in dedicated Redis serialization components
48+
- support heterogeneous metadata values through explicit converter handling
49+
- keep document materialization inside provider internals, not spread across query code paths
50+
- evolve document shape through deliberate package changes backed by ADRs when compatibility semantics change
51+
52+
## Design Rationale
53+
54+
- Reusing the governance model avoids translation layers that would duplicate request, approval, and decision semantics.
55+
- Centralized serialization keeps Redis persistence mechanics consistent across reads and writes.
56+
- Explicit converter support is necessary because governance metadata is intentionally flexible and may contain inferred object values.
57+
- A single persisted document contract makes provider behavior easier to reason about in tests, examples, and future migrations.
58+
59+
## Consequences
60+
61+
### Positive
62+
63+
- Redis persistence stays aligned with governance runtime semantics.
64+
- Serialization behavior is easier to test because all provider paths use the same document contract.
65+
- Metadata and nested governance structures can round-trip without ad hoc per-query parsing.
66+
- Future compatibility changes now have an explicit decision boundary.
67+
68+
### Negative
69+
70+
- The provider is coupled to the serialized shape of the governance request model.
71+
- Backward-compatible evolution requires discipline when changing serialized request members.
72+
- JSON document size grows with request history and approval detail.
73+
74+
## Related ADRs
75+
76+
- ADR-022: Governance Request Decisions and Storage
77+
- ADR-029: Governance Redis Provider Package
78+
- ADR-030: Governance Redis Request Storage and Query Strategy

Docs/Decision/listadr.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,9 @@ These ADRs describe the `ModularityKit.Mutator.Governance` extension layer and i
4444
| ADR-026 | Governance Request Query API | [ADR-026](Adr/ADR_026_Governance_Request_Query_API.md) |
4545
| ADR-027 | Governed Execution Manager | [ADR-027](Adr/ADR_027_Governed_Execution_Manager.md) |
4646
| ADR-028 | Governance Approval Workflow Hardening | [ADR-028](Adr/ADR_028_Governance_Approval_Workflow_Hardening.md) |
47+
| ADR-029 | Governance Redis Provider Package | [ADR-029](Adr/ADR_029_Governance_Redis_Provider_Package.md) |
48+
| ADR-030 | Governance Redis Request Storage and Query Strategy | [ADR-030](Adr/ADR_030_Governance_Redis_Request_Storage_and_Query_Strategy.md) |
49+
| ADR-031 | Governance Redis Serialization and Document Compatibility | [ADR-031](Adr/ADR_031_Governance_Redis_Serialization_and_Document_Compatibility.md) |
50+
| ADR-032 | Governance Redis Concurrency and Index Maintenance Model | [ADR-032](Adr/ADR_032_Governance_Redis_Concurrency_and_Index_Maintenance_Model.md) |
4751

4852
> See individual ADRs for detailed context, decision rationale, and consequences.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Abstractions.Intent;
3+
using ModularityKit.Mutator.Abstractions.Policies;
4+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
5+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Factory;
6+
using ModularityKit.Mutator.Governance.Redis.Serialization;
7+
using Xunit;
8+
9+
namespace ModularityKit.Mutator.Governance.Redis.Tests.Serialization;
10+
11+
public sealed class RedisMutationRequestSerializerTests
12+
{
13+
[Fact]
14+
public void Roundtrip_preserves_request_shape_needed_by_governance_runtime()
15+
{
16+
var request = MutationRequestFactory.PendingApproval(
17+
stateId: "tenant-42:roles",
18+
stateType: "IamRoleState",
19+
mutationType: "GrantRoleMutation",
20+
intent: new MutationIntent
21+
{
22+
OperationName = "GrantRole",
23+
Category = "Security",
24+
Description = "Grant elevated access",
25+
Tags = new HashSet<string> { "security", "urgent" },
26+
EstimatedBlastRadius = BlastRadius.Module,
27+
Metadata = new Dictionary<string, object>
28+
{
29+
["risk-owner"] = "platform"
30+
}
31+
},
32+
context: MutationContext.User("requester-1", "Requester One", "Need emergency access") with
33+
{
34+
StateId = "tenant-42:roles",
35+
Metadata = new Dictionary<string, object>
36+
{
37+
["source"] = "tests"
38+
}
39+
},
40+
requirements:
41+
[
42+
new PolicyRequirement
43+
{
44+
Type = "Approval",
45+
Description = "Requires security approval",
46+
Data = new Dictionary<string, object>
47+
{
48+
["Approver"] = "security-lead",
49+
["Reason"] = "Elevated role",
50+
["StepOrder"] = 1L,
51+
["RequiredApprovals"] = 1L
52+
}
53+
}
54+
],
55+
expectedStateVersion: "v10",
56+
metadata: new Dictionary<string, object>
57+
{
58+
["team"] = "security",
59+
["priority"] = "high"
60+
})
61+
with
62+
{
63+
CreatedAt = new DateTimeOffset(2026, 6, 25, 9, 0, 0, TimeSpan.Zero),
64+
UpdatedAt = new DateTimeOffset(2026, 6, 25, 9, 5, 0, TimeSpan.Zero)
65+
};
66+
67+
var json = RedisMutationRequestSerializer.Serialize(request);
68+
var roundtrip = RedisMutationRequestSerializer.Deserialize(json);
69+
70+
Assert.Equal(request.RequestId, roundtrip.RequestId);
71+
Assert.Equal(request.Status, roundtrip.Status);
72+
Assert.Equal(request.PendingReason, roundtrip.PendingReason);
73+
Assert.Equal(request.Intent.Category, roundtrip.Intent.Category);
74+
Assert.Contains("security", roundtrip.Intent.Tags);
75+
Assert.Equal(BlastRadiusScope.Module, roundtrip.Intent.EstimatedBlastRadius?.Scope);
76+
Assert.Equal("security", roundtrip.Metadata["team"]);
77+
Assert.Single(roundtrip.Requirements);
78+
Assert.Single(roundtrip.ApprovalRequirements);
79+
Assert.Equal("security-lead", roundtrip.ApprovalRequirements[0].ApproverId);
80+
Assert.Equal(3, roundtrip.Decisions.Count);
81+
}
82+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModularityKit.Mutator.Governance.Redis.Serialization.Converters;
5+
6+
/// <summary>
7+
/// Deserializes flexible metadata values into inferred CLR object graphs.
8+
/// </summary>
9+
internal sealed class InferredObjectJsonConverter : JsonConverter<object?>
10+
{
11+
/// <summary>
12+
/// Reads a JSON value into an inferred CLR object graph.
13+
/// </summary>
14+
/// <param name="reader">The JSON reader.</param>
15+
/// <param name="typeToConvert">The target type.</param>
16+
/// <param name="options">The serializer options.</param>
17+
/// <returns>The inferred CLR value.</returns>
18+
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
19+
=> ReadValue(ref reader);
20+
21+
/// <summary>
22+
/// Writes an inferred CLR value back to JSON.
23+
/// </summary>
24+
/// <param name="writer">The JSON writer.</param>
25+
/// <param name="value">The CLR value to write.</param>
26+
/// <param name="options">The serializer options.</param>
27+
public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options)
28+
{
29+
if (value is null)
30+
{
31+
writer.WriteNullValue();
32+
return;
33+
}
34+
35+
JsonSerializer.Serialize(writer, value, value.GetType(), options);
36+
}
37+
38+
private static object? ReadValue(ref Utf8JsonReader reader)
39+
{
40+
switch (reader.TokenType)
41+
{
42+
case JsonTokenType.True:
43+
return true;
44+
case JsonTokenType.False:
45+
return false;
46+
case JsonTokenType.Null:
47+
return null;
48+
case JsonTokenType.Number:
49+
if (reader.TryGetInt64(out var longValue))
50+
return longValue;
51+
52+
if (reader.TryGetDecimal(out var decimalValue))
53+
return decimalValue;
54+
55+
return reader.GetDouble();
56+
case JsonTokenType.String:
57+
return reader.GetString();
58+
case JsonTokenType.StartArray:
59+
{
60+
var list = new List<object?>();
61+
while (reader.Read())
62+
{
63+
if (reader.TokenType == JsonTokenType.EndArray)
64+
return list;
65+
66+
list.Add(ReadValue(ref reader));
67+
}
68+
69+
throw new JsonException("Unexpected end of JSON while reading array.");
70+
}
71+
case JsonTokenType.StartObject:
72+
{
73+
var dictionary = new Dictionary<string, object?>(StringComparer.Ordinal);
74+
75+
while (reader.Read())
76+
{
77+
if (reader.TokenType == JsonTokenType.EndObject)
78+
return dictionary;
79+
80+
if (reader.TokenType != JsonTokenType.PropertyName)
81+
throw new JsonException($"Unexpected token '{reader.TokenType}' while reading object.");
82+
83+
var propertyName = reader.GetString() ?? string.Empty;
84+
85+
if (!reader.Read())
86+
throw new JsonException("Unexpected end of JSON after property name.");
87+
88+
dictionary[propertyName] = ReadValue(ref reader);
89+
}
90+
91+
throw new JsonException("Unexpected end of JSON while reading object.");
92+
}
93+
default:
94+
throw new JsonException($"Unsupported JSON token '{reader.TokenType}' for inferred object conversion.");
95+
}
96+
}
97+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ModularityKit.Mutator.Governance.Redis.Serialization.Converters;
5+
6+
/// <summary>
7+
/// Creates converters for <see cref="IReadOnlySet{T}" /> payload members.
8+
/// </summary>
9+
internal sealed class ReadOnlySetJsonConverterFactory : JsonConverterFactory
10+
{
11+
/// <summary>
12+
/// Determines whether the supplied type is a supported read-only set type.
13+
/// </summary>
14+
/// <param name="typeToConvert">The type to inspect.</param>
15+
/// <returns><see langword="true" /> when the type is supported; otherwise <see langword="false" />.</returns>
16+
public override bool CanConvert(Type typeToConvert)
17+
=> typeToConvert.IsGenericType &&
18+
typeToConvert.GetGenericTypeDefinition() == typeof(IReadOnlySet<>);
19+
20+
/// <summary>
21+
/// Creates a converter instance for the supplied read-only set type.
22+
/// </summary>
23+
/// <param name="typeToConvert">The set type to convert.</param>
24+
/// <param name="options">The serializer options.</param>
25+
/// <returns>The created JSON converter.</returns>
26+
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
27+
{
28+
var itemType = typeToConvert.GetGenericArguments()[0];
29+
var converterType = typeof(ReadOnlySetJsonConverter<>).MakeGenericType(itemType);
30+
return (JsonConverter)Activator.CreateInstance(converterType)!;
31+
}
32+
33+
/// <summary>
34+
/// Converts a concrete read-only set payload for a specific item type.
35+
/// </summary>
36+
/// <typeparam name="T">The item type contained in the set.</typeparam>
37+
private sealed class ReadOnlySetJsonConverter<T> : JsonConverter<IReadOnlySet<T>>
38+
{
39+
/// <summary>
40+
/// Reads a JSON array into a read-only set.
41+
/// </summary>
42+
/// <param name="reader">The JSON reader.</param>
43+
/// <param name="typeToConvert">The target type.</param>
44+
/// <param name="options">The serializer options.</param>
45+
/// <returns>The materialized read-only set.</returns>
46+
public override IReadOnlySet<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
47+
{
48+
var values = JsonSerializer.Deserialize<HashSet<T>>(ref reader, options);
49+
return values ?? new HashSet<T>();
50+
}
51+
52+
/// <summary>
53+
/// Writes a read-only set as a JSON array.
54+
/// </summary>
55+
/// <param name="writer">The JSON writer.</param>
56+
/// <param name="value">The set value to write.</param>
57+
/// <param name="options">The serializer options.</param>
58+
public override void Write(Utf8JsonWriter writer, IReadOnlySet<T> value, JsonSerializerOptions options)
59+
=> JsonSerializer.Serialize(writer, value.ToArray(), options);
60+
}
61+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Text.Json;
2+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
3+
using ModularityKit.Mutator.Governance.Redis.Serialization.Converters;
4+
5+
namespace ModularityKit.Mutator.Governance.Redis.Serialization;
6+
7+
/// <summary>
8+
/// Serializes governed mutation requests to and from Redis JSON payloads.
9+
/// </summary>
10+
internal static class RedisMutationRequestSerializer
11+
{
12+
private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions();
13+
14+
/// <summary>
15+
/// Serializes a governed mutation request into a Redis JSON payload.
16+
/// </summary>
17+
/// <param name="request">The request to serialize.</param>
18+
/// <returns>The serialized JSON payload.</returns>
19+
public static string Serialize(MutationRequest request)
20+
{
21+
ArgumentNullException.ThrowIfNull(request);
22+
return JsonSerializer.Serialize(request, SerializerOptions);
23+
}
24+
25+
/// <summary>
26+
/// Deserializes a Redis JSON payload into a governed mutation request.
27+
/// </summary>
28+
/// <param name="json">The JSON payload to deserialize.</param>
29+
/// <returns>The deserialized mutation request.</returns>
30+
public static MutationRequest Deserialize(string json)
31+
{
32+
ArgumentException.ThrowIfNullOrWhiteSpace(json);
33+
34+
var request = JsonSerializer.Deserialize<MutationRequest>(json, SerializerOptions);
35+
if (request is null)
36+
throw new InvalidOperationException("Redis mutation request payload deserialized to null.");
37+
38+
return request;
39+
}
40+
41+
private static JsonSerializerOptions CreateSerializerOptions()
42+
{
43+
var options = new JsonSerializerOptions
44+
{
45+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
46+
};
47+
48+
options.Converters.Add(new InferredObjectJsonConverter());
49+
options.Converters.Add(new ReadOnlySetJsonConverterFactory());
50+
return options;
51+
}
52+
}

0 commit comments

Comments
 (0)