Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

namespace Aevatar.GAgents.ContentArtifacts;

public static class ContentArtifactConventions
{
public const string ActorIdPrefix = "content-artifact";
public const string PinActorIdPrefix = "content-artifact-pin";
public const int MaxInlineContentBytes = 64 * 1024;
public const int MaxLabelCount = 8;
public const int MaxLabelValueCharacters = 256;
private const string ReservedLabelPrefix = "aevatar.";
private static readonly Regex LabelKeyPattern = new(
"^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$",
RegexOptions.CultureInvariant);

public static string BuildArtifactId(string scopeId, string dedupKey)
{
Expand All @@ -19,6 +27,9 @@ public static string BuildArtifactId(string scopeId, string dedupKey)
public static string BuildActorId(string scopeId, string artifactId) =>
$"{ActorIdPrefix}:{NormalizeScopeId(scopeId)}:{NormalizeArtifactId(artifactId)}";

public static string BuildPinActorId(string scopeId, string pinKey) =>
$"{PinActorIdPrefix}:{NormalizeScopeId(scopeId)}:{NormalizeLabelKey(pinKey, nameof(pinKey))}";

public static string BuildRevisionId(string artifactId, long revisionNumber)
{
if (revisionNumber <= 0)
Expand All @@ -42,6 +53,66 @@ public static string NormalizeArtifactId(string? artifactId)
return normalized;
}

public static string NormalizeLabelKey(string? key, string parameterName)
{
var normalized = NormalizeRequired(key, parameterName);
if (!LabelKeyPattern.IsMatch(normalized))
{
throw new ArgumentException(
$"{parameterName} must match [a-z0-9]([a-z0-9._-]{{0,62}}[a-z0-9])?.",
parameterName);
}
if (normalized.StartsWith(ReservedLabelPrefix, StringComparison.Ordinal))
throw new ArgumentException($"{parameterName} must not use the reserved 'aevatar.' prefix.", parameterName);
return normalized;
}

public static string NormalizeLabelValue(string? value, string parameterName)
{
var normalized = NormalizeRequired(value, parameterName);
if (normalized.Contains('\r') || normalized.Contains('\n'))
throw new ArgumentException($"{parameterName} must be a single line.", parameterName);
if (normalized.EnumerateRunes().Take(MaxLabelValueCharacters + 1).Count() > MaxLabelValueCharacters)
{
throw new ArgumentException(
$"{parameterName} must be at most {MaxLabelValueCharacters} characters.",
parameterName);
}
return normalized;
}

// Implement (issue #3527):
// Behavior: labels are bounded, canonical creation facts and pin keys share their key grammar.
// Why this shape: typed validation keeps query paths stable without introducing a metadata bag.
public static IReadOnlyDictionary<string, string> NormalizeLabels(
IReadOnlyDictionary<string, string>? labels)
{
if (labels == null || labels.Count == 0)
return new SortedDictionary<string, string>(StringComparer.Ordinal);
if (labels.Count > MaxLabelCount)
throw new ArgumentException($"labels must contain at most {MaxLabelCount} entries.", nameof(labels));

var normalized = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in labels)
{
normalized.Add(
NormalizeLabelKey(key, "labels.key"),
NormalizeLabelValue(value, $"labels['{key}']"));
}
return normalized;
}

public static void ValidateCanonicalLabels(IReadOnlyDictionary<string, string> labels)
{
var normalized = NormalizeLabels(labels);
if (normalized.Count != labels.Count ||
normalized.Any(pair => !labels.TryGetValue(pair.Key, out var value) ||
!string.Equals(value, pair.Value, StringComparison.Ordinal)))
{
throw new InvalidOperationException("ContentArtifact labels must be canonical.");
}
}

public static string NormalizeRequired(string? value, string parameterName)
{
var normalized = value?.Trim();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ private async Task ValidateCreateAsync(CreateContentArtifact command)
if (command.Kind == ContentArtifactKind.Unspecified)
throw new InvalidOperationException("kind is required.");
ContentArtifactConventions.NormalizeRequired(command.Title, "title");
ContentArtifactConventions.ValidateCanonicalLabels(command.Labels);
ArgumentNullException.ThrowIfNull(command.AccessPolicy);
ValidatePrincipal(command.AccessPolicy.Owner, "access_policy.owner");
ArgumentNullException.ThrowIfNull(command.FirstRevision);
Expand Down Expand Up @@ -328,6 +329,7 @@ private static ContentArtifactState ApplyCreated(ContentArtifactState state, Con
UpdatedAtUtc = createdAt.Clone(),
CreationRequestHash = HashCreateRequest(request),
};
next.Labels.Add(request.Labels);
next.Revisions.Add(revision.RevisionId, revision);
return next;
}
Expand Down
237 changes: 237 additions & 0 deletions agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactPinGAgent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
using System.Security.Cryptography;
using Aevatar.ContentArtifacts.Abstractions;
using Aevatar.Foundation.Abstractions;
using Aevatar.Foundation.Abstractions.Attributes;
using Aevatar.Foundation.Abstractions.TypeSystem;
using Aevatar.Foundation.Core;
using Aevatar.Foundation.Core.EventSourcing;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;

namespace Aevatar.GAgents.ContentArtifacts;

/// <summary>
/// Authority for the single mutable ContentArtifact pointer identified by scope and pin key.
/// </summary>
[GAgent("studio.content-artifact-pin")]
public sealed class ContentArtifactPinGAgent : GAgentBase<ContentArtifactPinState>, IProjectedActor
{
public static string ProjectionKind => "content-artifact-pin";

// Implement (issue #3527):
// Behavior: one scope + pin_key actor atomically replaces the current pinned artifact.
// Why this shape: the cross-artifact uniqueness invariant must live in one authority actor.
[EventHandler(EndpointName = "setContentArtifactPin")]
public async Task HandleSetAsync(SetContentArtifactPinCommand command)
{
ArgumentNullException.ThrowIfNull(command);
var scopeId = ContentArtifactConventions.NormalizeScopeId(command.ScopeId);
var pinKey = ContentArtifactConventions.NormalizeLabelKey(command.PinKey, "pin_key");
var artifactId = ContentArtifactConventions.NormalizeArtifactId(command.ArtifactId);
ValidatePrincipal(command.RequestedBy);
ValidateExpectedVersion(command.ExpectedPinVersion);
var mutationId = ContentArtifactConventions.NormalizeRequired(command.MutationId, "mutation_id");
EnsureActorAddress(scopeId, pinKey);
var mutationHash = HashMutation(command);
if (IsReplay(mutationId, mutationHash))
return;

if (command.ExpectedPinVersion != State.PinVersion)
{
await PersistRejectedAsync(
scopeId,
pinKey,
command.RequestedBy,
mutationId,
mutationHash,
command.RequestedAtUtc);
return;
}

await PersistDomainEventAsync(new ContentArtifactPinSetEvent
{
ScopeId = scopeId,
PinKey = pinKey,
ArtifactId = artifactId,
PinnedBy = command.RequestedBy.Clone(),
PinVersion = checked(State.PinVersion + 1),
MutationId = mutationId,
MutationHash = mutationHash,
UpdatedAtUtc = ResolveRequestedAt(command.RequestedAtUtc),
});
}

[EventHandler(EndpointName = "clearContentArtifactPin")]
public async Task HandleClearAsync(ClearContentArtifactPinCommand command)
{
ArgumentNullException.ThrowIfNull(command);
var scopeId = ContentArtifactConventions.NormalizeScopeId(command.ScopeId);
var pinKey = ContentArtifactConventions.NormalizeLabelKey(command.PinKey, "pin_key");
ValidatePrincipal(command.RequestedBy);
ValidateExpectedVersion(command.ExpectedPinVersion);
var mutationId = ContentArtifactConventions.NormalizeRequired(command.MutationId, "mutation_id");
EnsureActorAddress(scopeId, pinKey);
var mutationHash = HashMutation(command);
if (IsReplay(mutationId, mutationHash))
return;

if (command.ExpectedPinVersion != State.PinVersion)
{
await PersistRejectedAsync(
scopeId,
pinKey,
command.RequestedBy,
mutationId,
mutationHash,
command.RequestedAtUtc);
return;
}

await PersistDomainEventAsync(new ContentArtifactPinClearedEvent
{
ScopeId = scopeId,
PinKey = pinKey,
RequestedBy = command.RequestedBy.Clone(),
PinVersion = checked(State.PinVersion + 1),
MutationId = mutationId,
MutationHash = mutationHash,
UpdatedAtUtc = ResolveRequestedAt(command.RequestedAtUtc),
});
}

protected override ContentArtifactPinState TransitionState(ContentArtifactPinState current, IMessage evt) =>
StateTransitionMatcher
.Match(current, evt)
.On<ContentArtifactPinSetEvent>(ApplySet)
.On<ContentArtifactPinClearedEvent>(ApplyCleared)
.On<ContentArtifactPinMutationRejectedEvent>(ApplyRejected)
.OrCurrent();

private Task PersistRejectedAsync(
string scopeId,
string pinKey,
ContentArtifactPrincipal requestedBy,
string mutationId,
ByteString mutationHash,
Timestamp? requestedAt) =>
PersistDomainEventAsync(new ContentArtifactPinMutationRejectedEvent
{
ScopeId = scopeId,
PinKey = pinKey,
RequestedBy = requestedBy.Clone(),
MutationId = mutationId,
MutationHash = mutationHash,
RejectionCode = ContentArtifactPinRejectionCode.PinVersionConflict,
RejectedAtUtc = ResolveRequestedAt(requestedAt),
});

private bool IsReplay(string mutationId, ByteString mutationHash)
{
if (!string.Equals(State.LastMutationId, mutationId, StringComparison.Ordinal))
return false;
if (State.LastMutationHash.Equals(mutationHash))
return true;
throw new InvalidOperationException(
$"ContentArtifact pin mutation_id '{mutationId}' was already used for different facts.");
}

private void EnsureActorAddress(string scopeId, string pinKey)
{
var expectedActorId = ContentArtifactConventions.BuildPinActorId(scopeId, pinKey);
if (!string.Equals(Id, expectedActorId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"ContentArtifact pin actor '{Id}' does not match canonical identity '{expectedActorId}'.");
}
if ((!string.IsNullOrWhiteSpace(State.ScopeId) &&
!string.Equals(State.ScopeId, scopeId, StringComparison.Ordinal)) ||
(!string.IsNullOrWhiteSpace(State.PinKey) &&
!string.Equals(State.PinKey, pinKey, StringComparison.Ordinal)))
{
throw new InvalidOperationException("ContentArtifact pin identity is immutable.");
}
}

private static ContentArtifactPinState ApplySet(
ContentArtifactPinState state,
ContentArtifactPinSetEvent evt) =>
new()
{
ScopeId = evt.ScopeId,
PinKey = evt.PinKey,
PinnedArtifactId = evt.ArtifactId,
PinnedBy = evt.PinnedBy.Clone(),
PinVersion = evt.PinVersion,
UpdatedAtUtc = evt.UpdatedAtUtc?.Clone(),
LastMutationId = evt.MutationId,
LastMutationHash = evt.MutationHash,
LastMutationStatus = ContentArtifactPinMutationStatus.Succeeded,
LastRejectionCode = ContentArtifactPinRejectionCode.Unspecified,
LastMutationRequestedBy = evt.PinnedBy.Clone(),
};

private static ContentArtifactPinState ApplyCleared(
ContentArtifactPinState state,
ContentArtifactPinClearedEvent evt) =>
new()
{
ScopeId = evt.ScopeId,
PinKey = evt.PinKey,
PinVersion = evt.PinVersion,
UpdatedAtUtc = evt.UpdatedAtUtc?.Clone(),
LastMutationId = evt.MutationId,
LastMutationHash = evt.MutationHash,
LastMutationStatus = ContentArtifactPinMutationStatus.Succeeded,
LastRejectionCode = ContentArtifactPinRejectionCode.Unspecified,
LastMutationRequestedBy = evt.RequestedBy.Clone(),
};

private static ContentArtifactPinState ApplyRejected(
ContentArtifactPinState state,
ContentArtifactPinMutationRejectedEvent evt)
{
var next = state.Clone();
next.ScopeId = evt.ScopeId;
next.PinKey = evt.PinKey;
next.UpdatedAtUtc = evt.RejectedAtUtc?.Clone();
next.LastMutationId = evt.MutationId;
next.LastMutationHash = evt.MutationHash;
next.LastMutationStatus = ContentArtifactPinMutationStatus.Rejected;
next.LastRejectionCode = evt.RejectionCode;
next.LastMutationRequestedBy = evt.RequestedBy.Clone();
return next;
}

private static void ValidatePrincipal(ContentArtifactPrincipal? principal)
{
if (principal == null ||
string.IsNullOrWhiteSpace(principal.PrincipalId) ||
string.IsNullOrWhiteSpace(principal.PrincipalKind))
{
throw new InvalidOperationException("requested_by principal_id and principal_kind are required.");
}
}

private static void ValidateExpectedVersion(long expectedPinVersion)
{
if (expectedPinVersion < 0)
throw new InvalidOperationException("expected_pin_version must be non-negative.");
}

private static ByteString HashMutation(SetContentArtifactPinCommand command)
{
var semantic = command.Clone();
semantic.RequestedAtUtc = null;
return ByteString.CopyFrom(SHA256.HashData(semantic.ToByteArray()));
}

private static ByteString HashMutation(ClearContentArtifactPinCommand command)
{
var semantic = command.Clone();
semantic.RequestedAtUtc = null;
return ByteString.CopyFrom(SHA256.HashData(semantic.ToByteArray()));
}

private static Timestamp ResolveRequestedAt(Timestamp? requestedAt) =>
requestedAt?.Clone() ?? Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow);
}
Loading
Loading