From 259ca5a2c751dd9c7cdf61508b9bcbdd0a212cdd Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 26 Aug 2026 03:01:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?Issue=20#3527:=20[ContentArtifact]=20?= =?UTF-8?q?=E6=8C=89=20metadata=20key=20=E7=9A=84=20list/filter=20?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=90=8C=20scope+kind=20=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E7=BD=AE=E9=A1=B6=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented per .implement-loop/runs/implement-issue-3527.md. Closes #3527 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ContentArtifactConventions.cs | 71 ++++++ .../ContentArtifactGAgent.cs | 2 + .../ContentArtifactPinGAgent.cs | 237 ++++++++++++++++++ docs/canon/content-artifacts.md | 41 +++ .../content_artifact_messages.proto | 77 ++++++ .../IContentArtifactCommandPort.cs | 17 ++ .../Abstractions/IContentArtifactQueryPort.cs | 8 + .../Abstractions/IContentArtifactService.cs | 7 + .../Contracts/ContentArtifactContracts.cs | 52 +++- .../ServiceCollectionExtensions.cs | 1 + .../Services/ContentArtifactPinService.cs | 132 ++++++++++ .../Studio/Services/ContentArtifactService.cs | 12 + .../Endpoints/ContentArtifactEndpoints.cs | 91 ++++++- ...ionReadModelServiceCollectionExtensions.cs | 4 + ...orDispatchContentArtifactCommandService.cs | 2 + ...ispatchContentArtifactPinCommandService.cs | 130 ++++++++++ .../ServiceCollectionExtensions.cs | 10 + ...actCurrentStateDocumentMetadataProvider.cs | 7 + ...PinCurrentStateDocumentMetadataProvider.cs | 17 ++ ...edStateProjectionActivationPlanProvider.cs | 1 + .../ContentArtifactCurrentStateProjector.cs | 1 + ...ContentArtifactPinCurrentStateProjector.cs | 97 +++++++ .../ProjectionContentArtifactPinQueryPort.cs | 55 ++++ .../ProjectionContentArtifactQueryPort.cs | 5 +- ...ArtifactPinCurrentStateDocument.Partial.cs | 13 + .../studio_projection_readmodels.proto | 23 ++ .../ContentArtifactCommandServiceTests.cs | 40 ++- .../ContentArtifactEndpointsTests.cs | 113 ++++++++- .../ContentArtifactGAgentTests.cs | 20 ++ .../ContentArtifactPinGAgentTests.cs | 133 ++++++++++ .../ContentArtifactPinServiceTests.cs | 234 +++++++++++++++++ .../ContentArtifactProjectionTests.cs | 131 +++++++++- .../ContentArtifactServiceTests.cs | 71 +++++- 33 files changed, 1844 insertions(+), 11 deletions(-) create mode 100644 agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactPinGAgent.cs create mode 100644 src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs create mode 100644 src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactPinCommandService.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/ContentArtifactPinCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/ContentArtifactPinCurrentStateDocument.Partial.cs create mode 100644 test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinGAgentTests.cs create mode 100644 test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs diff --git a/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactConventions.cs b/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactConventions.cs index c1c8fa3e5a..dac27a2903 100644 --- a/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactConventions.cs +++ b/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactConventions.cs @@ -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) { @@ -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) @@ -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 NormalizeLabels( + IReadOnlyDictionary? labels) + { + if (labels == null || labels.Count == 0) + return new SortedDictionary(StringComparer.Ordinal); + if (labels.Count > MaxLabelCount) + throw new ArgumentException($"labels must contain at most {MaxLabelCount} entries.", nameof(labels)); + + var normalized = new SortedDictionary(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 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(); diff --git a/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactGAgent.cs b/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactGAgent.cs index a45759fed4..9046fafd6e 100644 --- a/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactGAgent.cs +++ b/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactGAgent.cs @@ -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); @@ -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; } diff --git a/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactPinGAgent.cs b/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactPinGAgent.cs new file mode 100644 index 0000000000..72d2704626 --- /dev/null +++ b/agents/Aevatar.GAgents.ContentArtifacts/ContentArtifactPinGAgent.cs @@ -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; + +/// +/// Authority for the single mutable ContentArtifact pointer identified by scope and pin key. +/// +[GAgent("studio.content-artifact-pin")] +public sealed class ContentArtifactPinGAgent : GAgentBase, 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(ApplySet) + .On(ApplyCleared) + .On(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); +} diff --git a/docs/canon/content-artifacts.md b/docs/canon/content-artifacts.md index 2087f69aed..e62a459f66 100644 --- a/docs/canon/content-artifacts.md +++ b/docs/canon/content-artifacts.md @@ -37,6 +37,42 @@ present, creation validates that the Team exists and is active in the same Scope. A scope-owned artifact without a Team stores no invented Team identity. Team ownership records resource context; it grants no implicit artifact access. +## Immutable Labels + +An artifact may declare up to eight labels at creation. Labels are immutable +partition facts, not an open metadata bag: keys must match +`[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?`, the `aevatar.` prefix is reserved, +and values must be non-empty single-line strings of at most 256 characters. +They participate in the canonical creation request hash. Append, pointer +advance, redaction, expiry, and tombstone never modify them; changing a +partition requires creating an artifact under a new dedup key. + +List queries may supply exactly one `labelKey + labelValue` pair for exact +equality. The projection store applies `labels. == value` together with +Scope, ACL, and other filters before cursor paging. Range, full-text, and +multi-label predicates are outside this surface. + +## Scope Pin Pointers + +`ContentArtifactPinGAgent` is the authority for one mutable pointer identified +by `scopeId + pinKey`. A pin key follows the label-key rules and names a +consumer-defined artifact family such as `daily-ops-report`; it is not the +four-value ContentArtifact kind. Because every mutation for the same key reaches +one actor, set atomically replaces the prior artifact and at most one artifact +is pinned for that key. + +Set requires an ACTIVE target in the same Scope owned by the caller. Clear is +authorized from the committed `pinnedBy` fact so a stale or unavailable target +does not prevent explicit cleanup. The actor owns `pinVersion` CAS and +`mutationId` idempotency. Successful set and clear advance `pinVersion`; a CAS +conflict is persisted as a rejected mutation without changing the pointer or +`pinVersion`. The actor current-state read model exposes both authoritative +`pinVersion` and committed projection `stateVersion`. + +Artifact lifecycle does not cascade into the pin actor. If a pinned artifact is +later tombstoned or otherwise unavailable, consumers report +`pinned_target_unavailable` and explicitly clear or replace the pointer. + ## Immutable Revisions And CAS Creation commits revision 1 and makes it current. Append assigns the next @@ -152,6 +188,11 @@ Mutation responses are `202 Accepted` dispatch receipts. Clients observe committed state through the current-state query surface and its authoritative `stateVersion`; no endpoint implies query freshness from command acceptance. +The list endpoint accepts an optional paired `labelKey` and `labelValue`. +Pin pointers use `/api/scopes/{scopeId}/content-artifact-pins/{pinKey}` with +GET, PUT, and DELETE; PUT and DELETE return the same accepted-dispatch semantics +as artifact mutations. + Artifact absence and artifact-level ACL denial both return HTTP 404 on reads, mutations, and Run attachment. A missing revision is also 404. The shared `scopeId + dedupKey` namespace is intentionally observable only as occupancy: diff --git a/src/Aevatar.ContentArtifacts.Abstractions/content_artifact_messages.proto b/src/Aevatar.ContentArtifacts.Abstractions/content_artifact_messages.proto index 3a2d4f9990..cc18386f35 100644 --- a/src/Aevatar.ContentArtifacts.Abstractions/content_artifact_messages.proto +++ b/src/Aevatar.ContentArtifacts.Abstractions/content_artifact_messages.proto @@ -140,6 +140,7 @@ message ContentArtifactState { string tombstone_reason = 17; google.protobuf.Timestamp tombstoned_at_utc = 18; string creation_request_hash = 19; + map labels = 20; } message CreateContentArtifact { @@ -156,6 +157,7 @@ message CreateContentArtifact { ContentArtifactRevision first_revision = 11; int64 expected_concurrency_version = 12; google.protobuf.Timestamp requested_at_utc = 13; + map labels = 14; } message AppendContentArtifactRevision { @@ -229,3 +231,78 @@ message ContentArtifactTombstonedEvent { string reason = 1; google.protobuf.Timestamp tombstoned_at_utc = 2; } + +enum ContentArtifactPinMutationStatus { + CONTENT_ARTIFACT_PIN_MUTATION_STATUS_UNSPECIFIED = 0; + CONTENT_ARTIFACT_PIN_MUTATION_STATUS_SUCCEEDED = 1; + CONTENT_ARTIFACT_PIN_MUTATION_STATUS_REJECTED = 2; +} + +enum ContentArtifactPinRejectionCode { + CONTENT_ARTIFACT_PIN_REJECTION_CODE_UNSPECIFIED = 0; + CONTENT_ARTIFACT_PIN_REJECTION_CODE_PIN_VERSION_CONFLICT = 1; +} + +message ContentArtifactPinState { + string scope_id = 1; + string pin_key = 2; + string pinned_artifact_id = 3; + ContentArtifactPrincipal pinned_by = 4; + int64 pin_version = 5; + google.protobuf.Timestamp updated_at_utc = 6; + string last_mutation_id = 7; + bytes last_mutation_hash = 8; + ContentArtifactPinMutationStatus last_mutation_status = 9; + ContentArtifactPinRejectionCode last_rejection_code = 10; + ContentArtifactPrincipal last_mutation_requested_by = 11; +} + +message SetContentArtifactPinCommand { + string scope_id = 1; + string pin_key = 2; + string artifact_id = 3; + ContentArtifactPrincipal requested_by = 4; + int64 expected_pin_version = 5; + string mutation_id = 6; + google.protobuf.Timestamp requested_at_utc = 7; +} + +message ClearContentArtifactPinCommand { + string scope_id = 1; + string pin_key = 2; + ContentArtifactPrincipal requested_by = 3; + int64 expected_pin_version = 4; + string mutation_id = 5; + google.protobuf.Timestamp requested_at_utc = 6; +} + +message ContentArtifactPinSetEvent { + string scope_id = 1; + string pin_key = 2; + string artifact_id = 3; + ContentArtifactPrincipal pinned_by = 4; + int64 pin_version = 5; + string mutation_id = 6; + bytes mutation_hash = 7; + google.protobuf.Timestamp updated_at_utc = 8; +} + +message ContentArtifactPinClearedEvent { + string scope_id = 1; + string pin_key = 2; + ContentArtifactPrincipal requested_by = 3; + int64 pin_version = 4; + string mutation_id = 5; + bytes mutation_hash = 6; + google.protobuf.Timestamp updated_at_utc = 7; +} + +message ContentArtifactPinMutationRejectedEvent { + string scope_id = 1; + string pin_key = 2; + ContentArtifactPrincipal requested_by = 3; + string mutation_id = 4; + bytes mutation_hash = 5; + ContentArtifactPinRejectionCode rejection_code = 6; + google.protobuf.Timestamp rejected_at_utc = 7; +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactCommandPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactCommandPort.cs index f58319d96f..c55a2bb338 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactCommandPort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactCommandPort.cs @@ -47,3 +47,20 @@ Task TombstoneAsync( ContentArtifactPrincipalContract requester, CancellationToken ct = default); } + +public interface IContentArtifactPinCommandPort +{ + Task SetAsync( + string scopeId, + string pinKey, + SetContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default); + + Task ClearAsync( + string scopeId, + string pinKey, + ClearContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactQueryPort.cs index 1222cd2e3e..b22f13fb86 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactQueryPort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactQueryPort.cs @@ -27,3 +27,11 @@ Task GetRevisionContentAsync( ContentArtifactPrincipalContract requester, CancellationToken ct = default); } + +public interface IContentArtifactPinQueryPort +{ + Task GetAsync( + string scopeId, + string pinKey, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactService.cs index 4272edbf98..a77d2c5128 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactService.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IContentArtifactService.cs @@ -17,3 +17,10 @@ public interface IContentArtifactService Task TombstoneAsync(string scopeId, string artifactId, TombstoneContentArtifactRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default); Task AttachToRunAsync(string scopeId, AttachContentArtifactsToRunRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default); } + +public interface IContentArtifactPinService +{ + Task GetAsync(string scopeId, string pinKey, CancellationToken ct = default); + Task SetAsync(string scopeId, string pinKey, SetContentArtifactPinRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default); + Task ClearAsync(string scopeId, string pinKey, ClearContentArtifactPinRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs index 23bbbae674..bbf271c242 100644 --- a/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs @@ -100,7 +100,8 @@ public sealed record CreateContentArtifactRequest( ContentArtifactRevisionWriteRequest FirstRevision, ContentArtifactAccessPolicyContract? AccessPolicy = null, ContentArtifactRetentionPolicyContract? RetentionPolicy = null, - string? WorkOrderId = null); + string? WorkOrderId = null, + IReadOnlyDictionary? Labels = null); public sealed record AppendContentArtifactRevisionRequest( ContentArtifactRevisionWriteRequest Revision); @@ -171,7 +172,8 @@ public sealed record ContentArtifactCurrentStateResponse( DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc, string? TombstoneReason = null, - DateTimeOffset? TombstonedAtUtc = null); + DateTimeOffset? TombstonedAtUtc = null, + IReadOnlyDictionary? Labels = null); public sealed record ContentArtifactListResponse( string ScopeId, @@ -185,7 +187,38 @@ public sealed record ContentArtifactQueryRequest( string? Kind = null, string? LifecycleStatus = null, string? WorkOrderId = null, - string? RunId = null); + string? RunId = null, + string? LabelKey = null, + string? LabelValue = null); + +public sealed record SetContentArtifactPinRequest( + string ArtifactId, + long ExpectedPinVersion, + string MutationId); + +public sealed record ClearContentArtifactPinRequest( + long ExpectedPinVersion, + string MutationId); + +public sealed record ContentArtifactPinCurrentStateResponse( + string ScopeId, + string PinKey, + string? PinnedArtifactId, + ContentArtifactPrincipalContract? PinnedBy, + long PinVersion, + long StateVersion, + DateTimeOffset UpdatedAtUtc, + string LastMutationId, + string LastMutationStatus, + string? LastRejectionCode = null); + +public sealed record ContentArtifactPinAcceptedReceipt( + string ScopeId, + string PinKey, + string CommandId, + string CorrelationId, + string Stage, + DateTimeOffset? AcceptedAtUtc = null); public sealed record ContentArtifactRevisionContentResponse( ContentArtifactReferenceContract Reference, @@ -223,6 +256,19 @@ public ContentArtifactIdentityConflictException(string dedupKey) public string DedupKey { get; } } +public sealed class ContentArtifactPinNotFoundException : InvalidOperationException +{ + public ContentArtifactPinNotFoundException(string scopeId, string pinKey) + : base($"ContentArtifact pin '{pinKey}' was not found in scope '{scopeId}'.") + { + ScopeId = scopeId; + PinKey = pinKey; + } + + public string ScopeId { get; } + public string PinKey { get; } +} + public sealed class ContentArtifactContentUnavailableException : InvalidOperationException { // Fix (review round 1, F4): diff --git a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs index 59b44ed2b3..6ba93f20f4 100644 --- a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs @@ -70,6 +70,7 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se provider.GetRequiredService()); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs b/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs new file mode 100644 index 0000000000..0e76495943 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs @@ -0,0 +1,132 @@ +using Aevatar.GAgents.ContentArtifacts; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Application.Studio.Services; + +public sealed class ContentArtifactPinService : IContentArtifactPinService +{ + private readonly IContentArtifactQueryPort _artifactQueryPort; + private readonly IContentArtifactPinQueryPort _pinQueryPort; + private readonly IContentArtifactPinCommandPort _pinCommandPort; + + public ContentArtifactPinService( + IContentArtifactQueryPort artifactQueryPort, + IContentArtifactPinQueryPort pinQueryPort, + IContentArtifactPinCommandPort pinCommandPort) + { + _artifactQueryPort = artifactQueryPort ?? throw new ArgumentNullException(nameof(artifactQueryPort)); + _pinQueryPort = pinQueryPort ?? throw new ArgumentNullException(nameof(pinQueryPort)); + _pinCommandPort = pinCommandPort ?? throw new ArgumentNullException(nameof(pinCommandPort)); + } + + public async Task GetAsync( + string scopeId, + string pinKey, + CancellationToken ct = default) + { + var (normalizedScopeId, normalizedPinKey) = NormalizeIdentity(scopeId, pinKey); + var current = await GetCurrentAsync(normalizedScopeId, normalizedPinKey, ct); + if (current == null || string.IsNullOrWhiteSpace(current.PinnedArtifactId)) + throw new ContentArtifactPinNotFoundException(normalizedScopeId, normalizedPinKey); + return current; + } + + // Implement (issue #3527): + // Behavior: set validates that the target is ACTIVE, in-scope, and owned by the caller. + // Why this shape: target authorization is advisory application policy; pointer CAS remains actor-owned. + public async Task SetAsync( + string scopeId, + string pinKey, + SetContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + var (normalizedScopeId, normalizedPinKey) = NormalizeIdentity(scopeId, pinKey); + var normalizedRequester = NormalizePrincipal(requester); + var artifactId = ContentArtifactConventions.NormalizeArtifactId(request.ArtifactId); + var mutationId = ContentArtifactConventions.NormalizeRequired(request.MutationId, nameof(request.MutationId)); + ValidateExpectedVersion(request.ExpectedPinVersion); + + var artifact = await _artifactQueryPort.GetAsync(normalizedScopeId, artifactId, ct); + if (artifact == null || + !string.Equals(artifact.ScopeId, normalizedScopeId, StringComparison.Ordinal) || + !string.Equals(artifact.LifecycleStatus, ContentArtifactLifecycleStatusNames.Active, StringComparison.Ordinal) || + !PrincipalEquals(artifact.Owner, normalizedRequester)) + { + throw new ContentArtifactNotFoundException(normalizedScopeId, artifactId); + } + + return await _pinCommandPort.SetAsync( + normalizedScopeId, + normalizedPinKey, + request with { ArtifactId = artifactId, MutationId = mutationId }, + normalizedRequester, + ct); + } + + public async Task ClearAsync( + string scopeId, + string pinKey, + ClearContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + var (normalizedScopeId, normalizedPinKey) = NormalizeIdentity(scopeId, pinKey); + var normalizedRequester = NormalizePrincipal(requester); + var mutationId = ContentArtifactConventions.NormalizeRequired(request.MutationId, nameof(request.MutationId)); + ValidateExpectedVersion(request.ExpectedPinVersion); + var current = await GetCurrentAsync(normalizedScopeId, normalizedPinKey, ct); + if (current == null || + string.IsNullOrWhiteSpace(current.PinnedArtifactId) || + current.PinnedBy == null || + !PrincipalEquals(current.PinnedBy, normalizedRequester)) + { + throw new ContentArtifactPinNotFoundException(normalizedScopeId, normalizedPinKey); + } + return await _pinCommandPort.ClearAsync( + normalizedScopeId, + normalizedPinKey, + request with { MutationId = mutationId }, + normalizedRequester, + ct); + } + + private async Task GetCurrentAsync( + string scopeId, + string pinKey, + CancellationToken ct) + { + var current = await _pinQueryPort.GetAsync(scopeId, pinKey, ct); + return current != null && + string.Equals(current.ScopeId, scopeId, StringComparison.Ordinal) && + string.Equals(current.PinKey, pinKey, StringComparison.Ordinal) + ? current + : null; + } + + private static (string ScopeId, string PinKey) NormalizeIdentity(string scopeId, string pinKey) => + (ContentArtifactConventions.NormalizeScopeId(scopeId), + ContentArtifactConventions.NormalizeLabelKey(pinKey, nameof(pinKey))); + + private static ContentArtifactPrincipalContract NormalizePrincipal(ContentArtifactPrincipalContract requester) + { + ArgumentNullException.ThrowIfNull(requester); + return new ContentArtifactPrincipalContract( + ContentArtifactConventions.NormalizeRequired(requester.PrincipalId, "requester.principalId"), + ContentArtifactConventions.NormalizeRequired(requester.PrincipalKind, "requester.principalKind")); + } + + private static bool PrincipalEquals( + ContentArtifactPrincipalContract left, + ContentArtifactPrincipalContract right) => + string.Equals(left.PrincipalId, right.PrincipalId, StringComparison.Ordinal); + + private static void ValidateExpectedVersion(long expectedPinVersion) + { + if (expectedPinVersion < 0) + throw new InvalidOperationException("expectedPinVersion must be non-negative."); + } +} diff --git a/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactService.cs b/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactService.cs index 4924bba9b4..bfaf04905d 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactService.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using Aevatar.ContentArtifacts.Abstractions; +using Aevatar.GAgents.ContentArtifacts; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; @@ -456,6 +457,7 @@ private static CreateContentArtifactRequest NormalizeCreateRequest( NormalizeRequired(request.RetentionPolicy.PolicyId, "retentionPolicy.policyId"), request.RetentionPolicy.ExpiresAtUtc), WorkOrderId = NormalizeOptional(request.WorkOrderId), + Labels = ContentArtifactConventions.NormalizeLabels(request.Labels), }; } @@ -524,6 +526,10 @@ private static ContentArtifactCitationContract NormalizeCitation(ContentArtifact private static ContentArtifactQueryRequest NormalizeQuery(ContentArtifactQueryRequest? query) { query ??= new ContentArtifactQueryRequest(); + var hasLabelKey = !string.IsNullOrWhiteSpace(query.LabelKey); + var hasLabelValue = !string.IsNullOrWhiteSpace(query.LabelValue); + if (hasLabelKey != hasLabelValue) + throw new InvalidOperationException("labelKey and labelValue must be provided together."); return query with { PageToken = NormalizeOptional(query.PageToken), @@ -532,6 +538,12 @@ private static ContentArtifactQueryRequest NormalizeQuery(ContentArtifactQueryRe LifecycleStatus = NormalizeOptional(query.LifecycleStatus), WorkOrderId = NormalizeOptional(query.WorkOrderId), RunId = NormalizeOptional(query.RunId), + LabelKey = hasLabelKey + ? ContentArtifactConventions.NormalizeLabelKey(query.LabelKey, nameof(query.LabelKey)) + : null, + LabelValue = hasLabelValue + ? ContentArtifactConventions.NormalizeLabelValue(query.LabelValue, nameof(query.LabelValue)) + : null, }; } diff --git a/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs index 13d52ccc21..298fcd1211 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs @@ -26,6 +26,9 @@ public static void Map(IEndpointRouteBuilder app) app.MapPost("/api/scopes/{scopeId}/content-artifacts/{artifactId}/revisions/{revisionId}:expire", HandleExpireRevisionAsync).WithTags("ContentArtifacts"); app.MapPost("/api/scopes/{scopeId}/content-artifacts/{artifactId}:tombstone", HandleTombstoneAsync).WithTags("ContentArtifacts"); app.MapPost("/api/scopes/{scopeId}/content-artifacts:attach-to-run", HandleAttachToRunAsync).WithTags("ContentArtifacts"); + app.MapGet("/api/scopes/{scopeId}/content-artifact-pins/{pinKey}", HandleGetPinAsync).WithTags("ContentArtifacts"); + app.MapPut("/api/scopes/{scopeId}/content-artifact-pins/{pinKey}", HandleSetPinAsync).WithTags("ContentArtifacts"); + app.MapDelete("/api/scopes/{scopeId}/content-artifact-pins/{pinKey}", HandleClearPinAsync).WithTags("ContentArtifacts"); } internal static async Task HandleCreateAsync( @@ -67,6 +70,8 @@ internal static async Task HandleListAsync( string? lifecycleStatus, string? workOrderId, string? runId, + string? labelKey, + string? labelValue, CancellationToken ct) { if (!TryAuthorize(http, scopeId, out var principal, out var denied)) @@ -82,7 +87,9 @@ internal static async Task HandleListAsync( kind, lifecycleStatus, workOrderId, - runId), + runId, + labelKey, + labelValue), principal, ct)); } @@ -255,6 +262,82 @@ internal static async Task HandleAttachToRunAsync( } } + internal static async Task HandleGetPinAsync( + HttpContext http, + string scopeId, + string pinKey, + [FromServices] IContentArtifactPinService service, + CancellationToken ct) + { + if (!TryAuthorize(http, scopeId, out _, out var denied)) + return denied; + try + { + return Results.Ok(await service.GetAsync(scopeId, pinKey, ct)); + } + catch (ContentArtifactPinNotFoundException ex) + { + return PinNotFound(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_QUERY", ex.Message); + } + } + + // Implement (issue #3527): + // Behavior: pin mutations expose accepted dispatch receipts at the scope + pin_key resource. + // Why this shape: HTTP acknowledges dispatch while committed state remains observable via GET. + internal static async Task HandleSetPinAsync( + HttpContext http, + string scopeId, + string pinKey, + SetContentArtifactPinRequest request, + [FromServices] IContentArtifactPinService service, + CancellationToken ct) + { + if (!TryAuthorize(http, scopeId, out var principal, out var denied)) + return denied; + try + { + var receipt = await service.SetAsync(scopeId, pinKey, request, principal, ct); + return Results.Accepted(BuildPinLocation(scopeId, pinKey), receipt); + } + catch (ContentArtifactNotFoundException ex) + { + return NotFound(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_COMMAND", ex.Message); + } + } + + internal static async Task HandleClearPinAsync( + HttpContext http, + string scopeId, + string pinKey, + ClearContentArtifactPinRequest request, + [FromServices] IContentArtifactPinService service, + CancellationToken ct) + { + if (!TryAuthorize(http, scopeId, out var principal, out var denied)) + return denied; + try + { + var receipt = await service.ClearAsync(scopeId, pinKey, request, principal, ct); + return Results.Accepted(BuildPinLocation(scopeId, pinKey), receipt); + } + catch (ContentArtifactPinNotFoundException ex) + { + return PinNotFound(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_COMMAND", ex.Message); + } + } + private static async Task HandleReadAsync( HttpContext http, string scopeId, @@ -345,9 +428,15 @@ private static bool TryResolvePrincipal( private static string BuildLocation(string scopeId, string artifactId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/content-artifacts/{Uri.EscapeDataString(artifactId)}"; + private static string BuildPinLocation(string scopeId, string pinKey) => + $"/api/scopes/{Uri.EscapeDataString(scopeId)}/content-artifact-pins/{Uri.EscapeDataString(pinKey)}"; + private static IResult BadRequest(string code, string message) => Results.BadRequest(new { code, message }); private static IResult NotFound(string message) => Results.NotFound(new { code = "CONTENT_ARTIFACT_NOT_FOUND", message }); + + private static IResult PinNotFound(string message) => + Results.NotFound(new { code = "CONTENT_ARTIFACT_PIN_NOT_FOUND", message }); } diff --git a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs index 5911b2e52a..989e5b7bd0 100644 --- a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs @@ -91,6 +91,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders( IStudioWorkspaceVersionRegressionRepairService, StudioWorkspaceVersionRegressionRepairService>(); RegisterElasticsearch(services, configuration); + RegisterElasticsearch(services, configuration); RegisterElasticsearch(services, configuration); RegisterElasticsearch(services, configuration); } @@ -125,6 +126,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders( RegisterInMemory(services); RegisterInMemory(services); RegisterInMemory(services); + RegisterInMemory(services); RegisterInMemory(services); RegisterInMemory(services); } @@ -208,6 +210,7 @@ private static bool HasAllStudioDocumentReaders( && HasDocumentReaderForProvider(services, providerKind) && HasDocumentReaderForProvider(services, providerKind) && HasDocumentReaderForProvider(services, providerKind) + && HasDocumentReaderForProvider(services, providerKind) && HasDocumentReaderForProvider(services, providerKind) && HasDocumentReaderForProvider(services, providerKind); } @@ -261,6 +264,7 @@ private static TypeRegistry BuildStudioStateTypeRegistry() StudioTeamState.Descriptor, StudioWorkspaceState.Descriptor, ContentArtifactState.Descriptor, + ContentArtifactPinState.Descriptor, WorkOrderState.Descriptor, WorkflowDeliveryState.Descriptor); } diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactCommandService.cs index bddb29a7f0..085b1e7808 100644 --- a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactCommandService.cs +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactCommandService.cs @@ -52,6 +52,8 @@ public Task CreateAsync( }; if (ToRetentionPolicy(request.RetentionPolicy) is { } retentionPolicy) command.RetentionPolicy = retentionPolicy; + foreach (var (key, value) in request.Labels ?? new Dictionary(StringComparer.Ordinal)) + command.Labels.Add(key, value); return DispatchAsync(scopeId, artifactId, command, "create", 0, ct); } diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactPinCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactPinCommandService.cs new file mode 100644 index 0000000000..3ddee4e0b2 --- /dev/null +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchContentArtifactPinCommandService.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; +using Aevatar.ContentArtifacts.Abstractions; +using Aevatar.GAgents.ContentArtifacts; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.CommandServices; + +internal sealed class ActorDispatchContentArtifactPinCommandService : IContentArtifactPinCommandPort +{ + private const string PublisherId = "aevatar.studio.projection.content-artifact-pin"; + private readonly IStudioActorBootstrap _bootstrap; + private readonly StudioProjectionActorCommandDispatch _commandDispatch; + + public ActorDispatchContentArtifactPinCommandService( + IStudioActorBootstrap bootstrap, + StudioProjectionActorCommandDispatch commandDispatch) + { + _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _commandDispatch = commandDispatch ?? throw new ArgumentNullException(nameof(commandDispatch)); + } + + public Task SetAsync( + string scopeId, + string pinKey, + SetContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default) => + DispatchAsync( + scopeId, + pinKey, + new SetContentArtifactPinCommand + { + ScopeId = scopeId, + PinKey = pinKey, + ArtifactId = request.ArtifactId, + RequestedBy = ToPrincipal(requester), + ExpectedPinVersion = request.ExpectedPinVersion, + MutationId = request.MutationId, + RequestedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + "set", + ct); + + public Task ClearAsync( + string scopeId, + string pinKey, + ClearContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default) => + DispatchAsync( + scopeId, + pinKey, + new ClearContentArtifactPinCommand + { + ScopeId = scopeId, + PinKey = pinKey, + RequestedBy = ToPrincipal(requester), + ExpectedPinVersion = request.ExpectedPinVersion, + MutationId = request.MutationId, + RequestedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + "clear", + ct); + + private async Task DispatchAsync( + string scopeId, + string pinKey, + IMessage payload, + string operation, + CancellationToken ct) + { + var normalizedScopeId = ContentArtifactConventions.NormalizeScopeId(scopeId); + var normalizedPinKey = ContentArtifactConventions.NormalizeLabelKey(pinKey, nameof(pinKey)); + var actorId = ContentArtifactConventions.BuildPinActorId(normalizedScopeId, normalizedPinKey); + var commandId = BuildCommandId(operation, normalizedPinKey, payload); + var actor = await _bootstrap.EnsureAsync(actorId, ct); + var receipt = await _commandDispatch.DispatchAsync( + actor, + payload, + PublisherId, + commandId, + commandId, + commandId, + ct); + return new ContentArtifactPinAcceptedReceipt( + normalizedScopeId, + normalizedPinKey, + receipt.CommandId, + receipt.CorrelationId, + ContentArtifactCommandStageNames.DispatchAccepted, + receipt.AckedAt); + } + + private static string BuildCommandId(string operation, string pinKey, IMessage payload) + { + IMessage canonical = payload switch + { + SetContentArtifactPinCommand command => Canonical(command), + ClearContentArtifactPinCommand command => Canonical(command), + _ => throw new InvalidOperationException( + $"Unsupported ContentArtifact pin command payload '{payload.Descriptor.FullName}'."), + }; + var digest = Convert.ToHexStringLower(SHA256.HashData(canonical.ToByteArray())); + return $"content-artifact-pin-{operation}-{pinKey}-{digest}"; + } + + private static SetContentArtifactPinCommand Canonical(SetContentArtifactPinCommand command) + { + var canonical = command.Clone(); + canonical.RequestedAtUtc = null; + return canonical; + } + + private static ClearContentArtifactPinCommand Canonical(ClearContentArtifactPinCommand command) + { + var canonical = command.Clone(); + canonical.RequestedAtUtc = null; + return canonical; + } + + private static ContentArtifactPrincipal ToPrincipal(ContentArtifactPrincipalContract principal) => + new() + { + PrincipalId = principal.PrincipalId, + PrincipalKind = principal.PrincipalKind, + }; +} diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 52d6337811..e8ad395b16 100644 --- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -134,6 +134,10 @@ public static IServiceCollection AddStudioProjectionComponents( StudioMaterializationContext, ContentArtifactCurrentStateProjector>(); + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + ContentArtifactPinCurrentStateProjector>(); + services.AddCurrentStateProjectionMaterializer< StudioMaterializationContext, WorkOrderCurrentStateProjector>(); @@ -248,6 +252,10 @@ public static IServiceCollection AddStudioProjectionComponents( IProjectionDocumentMetadataProvider, ContentArtifactCurrentStateDocumentMetadataProvider>(); + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + ContentArtifactPinCurrentStateDocumentMetadataProvider>(); + services.TryAddSingleton< IProjectionDocumentMetadataProvider, WorkOrderCurrentStateDocumentMetadataProvider>(); @@ -293,6 +301,7 @@ public static IServiceCollection AddStudioProjectionComponents( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -317,6 +326,7 @@ public static IServiceCollection AddStudioProjectionComponents( IStudioMemberWorkflowScheduleProvisioningPort, StudioMemberWorkflowScheduleProvisioningExecutionPort>(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton< IWorkflowDeliveryCommandPort, diff --git a/src/Aevatar.Studio.Projection/Metadata/ContentArtifactCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/ContentArtifactCurrentStateDocumentMetadataProvider.cs index f0564e084e..d8d0c84ecf 100644 --- a/src/Aevatar.Studio.Projection/Metadata/ContentArtifactCurrentStateDocumentMetadataProvider.cs +++ b/src/Aevatar.Studio.Projection/Metadata/ContentArtifactCurrentStateDocumentMetadataProvider.cs @@ -11,6 +11,13 @@ public sealed class ContentArtifactCurrentStateDocumentMetadataProvider Mappings: new Dictionary(StringComparer.Ordinal) { ["dynamic"] = true, + ["properties"] = new Dictionary(StringComparer.Ordinal) + { + ["labels"] = new Dictionary(StringComparer.Ordinal) + { + ["type"] = "flattened", + }, + }, }, Settings: new Dictionary(StringComparer.Ordinal), Aliases: new Dictionary(StringComparer.Ordinal)); diff --git a/src/Aevatar.Studio.Projection/Metadata/ContentArtifactPinCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/ContentArtifactPinCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 0000000000..3b8677fe1a --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/ContentArtifactPinCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class ContentArtifactPinCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-content-artifact-pins", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Orchestration/StudioCommittedStateProjectionActivationPlanProvider.cs b/src/Aevatar.Studio.Projection/Orchestration/StudioCommittedStateProjectionActivationPlanProvider.cs index 7427973d61..c3165ffcff 100644 --- a/src/Aevatar.Studio.Projection/Orchestration/StudioCommittedStateProjectionActivationPlanProvider.cs +++ b/src/Aevatar.Studio.Projection/Orchestration/StudioCommittedStateProjectionActivationPlanProvider.cs @@ -37,6 +37,7 @@ public sealed class StudioCommittedStateProjectionActivationPlanProvider : IProj [typeof(StudioMemberBindingRunGAgent)] = StudioMemberBindingRunGAgent.ProjectionKind, [typeof(StudioTeamGAgent)] = StudioTeamGAgent.ProjectionKind, [typeof(ContentArtifactGAgent)] = ContentArtifactGAgent.ProjectionKind, + [typeof(ContentArtifactPinGAgent)] = ContentArtifactPinGAgent.ProjectionKind, [typeof(WorkOrderGAgent)] = WorkOrderGAgent.ProjectionKind, [typeof(WorkflowDeliveryGAgent)] = WorkflowDeliveryGAgent.ProjectionKind, [typeof(StudioWorkspaceGAgent)] = StudioWorkspaceGAgent.ProjectionKind, diff --git a/src/Aevatar.Studio.Projection/Projectors/ContentArtifactCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/ContentArtifactCurrentStateProjector.cs index 810f1f4c64..0dfe234e47 100644 --- a/src/Aevatar.Studio.Projection/Projectors/ContentArtifactCurrentStateProjector.cs +++ b/src/Aevatar.Studio.Projection/Projectors/ContentArtifactCurrentStateProjector.cs @@ -90,6 +90,7 @@ public static ContentArtifactCurrentStateDocument ToDocument( document.ReaderPrincipalIds.Add(state.AccessPolicy.ReaderPrincipalIds); document.WriterPrincipalIds.Add(state.AccessPolicy.WriterPrincipalIds); } + document.Labels.Add(state.Labels); foreach (var revision in state.Revisions.Values.OrderBy(static item => item.RevisionNumber)) { diff --git a/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs new file mode 100644 index 0000000000..ae4f554339 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs @@ -0,0 +1,97 @@ +using Aevatar.ContentArtifacts.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Projectors; + +public sealed class ContentArtifactPinCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public ContentArtifactPinCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent == null || + state == null || + string.IsNullOrWhiteSpace(state.ScopeId) || + string.IsNullOrWhiteSpace(state.PinKey)) + { + return; + } + + await _writeDispatcher.UpsertAsync( + ToDocument( + context.RootActorId, + stateEvent, + state, + CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow)), + ct); + } + + // Implement (issue #3527): + // Behavior: materialize the actor-owned pointer and authoritative pin_version verbatim. + // Why this shape: projection is a current-state replica and performs no uniqueness logic. + public static ContentArtifactPinCurrentStateDocument ToDocument( + string actorId, + StateEvent stateEvent, + ContentArtifactPinState state, + DateTimeOffset observedAt) + { + ArgumentNullException.ThrowIfNull(stateEvent); + ArgumentNullException.ThrowIfNull(state); + return new ContentArtifactPinCurrentStateDocument + { + Id = actorId, + ActorId = actorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(observedAt), + ScopeId = state.ScopeId, + PinKey = state.PinKey, + PinnedArtifactId = state.PinnedArtifactId, + PinnedByPrincipalId = state.PinnedBy?.PrincipalId ?? string.Empty, + PinnedByPrincipalKind = state.PinnedBy?.PrincipalKind ?? string.Empty, + PinVersion = state.PinVersion, + PinUpdatedAtUtc = state.UpdatedAtUtc?.Clone(), + LastMutationId = state.LastMutationId, + LastMutationStatus = ToWireName(state.LastMutationStatus), + LastRejectionCode = ToWireName(state.LastRejectionCode), + }; + } + + private static string ToWireName(ContentArtifactPinMutationStatus status) => status switch + { + ContentArtifactPinMutationStatus.Succeeded => "succeeded", + ContentArtifactPinMutationStatus.Rejected => "rejected", + _ => string.Empty, + }; + + private static string ToWireName(ContentArtifactPinRejectionCode code) => code switch + { + ContentArtifactPinRejectionCode.PinVersionConflict => "pin_version_conflict", + _ => string.Empty, + }; +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs new file mode 100644 index 0000000000..c4dfe69ae3 --- /dev/null +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs @@ -0,0 +1,55 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.GAgents.ContentArtifacts; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.QueryPorts; + +public sealed class ProjectionContentArtifactPinQueryPort : IContentArtifactPinQueryPort +{ + private readonly IProjectionDocumentReader _documentReader; + + public ProjectionContentArtifactPinQueryPort( + IProjectionDocumentReader documentReader) + { + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + } + + public async Task GetAsync( + string scopeId, + string pinKey, + CancellationToken ct = default) + { + var normalizedScopeId = ContentArtifactConventions.NormalizeScopeId(scopeId); + var normalizedPinKey = ContentArtifactConventions.NormalizeLabelKey(pinKey, nameof(pinKey)); + var document = await _documentReader.GetAsync( + ContentArtifactConventions.BuildPinActorId(normalizedScopeId, normalizedPinKey), + ct); + if (document == null || + !string.Equals(document.ScopeId, normalizedScopeId, StringComparison.Ordinal) || + !string.Equals(document.PinKey, normalizedPinKey, StringComparison.Ordinal)) + { + return null; + } + + return new ContentArtifactPinCurrentStateResponse( + document.ScopeId, + document.PinKey, + NormalizeOptional(document.PinnedArtifactId), + string.IsNullOrWhiteSpace(document.PinnedByPrincipalId) + ? null + : new ContentArtifactPrincipalContract( + document.PinnedByPrincipalId, + document.PinnedByPrincipalKind), + document.PinVersion, + document.StateVersion, + document.PinUpdatedAtUtc?.ToDateTimeOffset() ?? DateTimeOffset.MinValue, + document.LastMutationId, + document.LastMutationStatus, + NormalizeOptional(document.LastRejectionCode)); + } + + private static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactQueryPort.cs index c47932afd0..0b00e61013 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactQueryPort.cs @@ -43,6 +43,8 @@ public async Task ListAsync( AddOptionalEqual(filters, "lifecycle_status", query.LifecycleStatus); AddOptionalEqual(filters, "work_order_id", query.WorkOrderId); AddOptionalEqual(filters, "provenance_run_ids", query.RunId); + if (query.LabelKey != null && query.LabelValue != null) + filters.Add(Equal($"labels.{query.LabelKey}", query.LabelValue)); var pageSize = query.PageSize is > 0 and <= MaxPageSize ? query.PageSize.Value : MaxPageSize; @@ -228,7 +230,8 @@ private static ContentArtifactCurrentStateResponse ToResponse(ContentArtifactCur document.CreatedAtUtc?.ToDateTimeOffset() ?? DateTimeOffset.MinValue, document.ArtifactUpdatedAtUtc?.ToDateTimeOffset() ?? DateTimeOffset.MinValue, NormalizeOptional(document.TombstoneReason), - document.TombstonedAtUtc?.ToDateTimeOffset()); + document.TombstonedAtUtc?.ToDateTimeOffset(), + new Dictionary(document.Labels, StringComparer.Ordinal)); private static void EnsureReadAuthorized( ContentArtifactCurrentStateDocument document, diff --git a/src/Aevatar.Studio.Projection/ReadModels/ContentArtifactPinCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/ContentArtifactPinCurrentStateDocument.Partial.cs new file mode 100644 index 0000000000..9b56b896c6 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/ContentArtifactPinCurrentStateDocument.Partial.cs @@ -0,0 +1,13 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class ContentArtifactPinCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + long IProjectionReadModel.StateVersion => StateVersion; + string IProjectionReadModel.LastEventId => LastEventId; + DateTimeOffset IProjectionReadModel.UpdatedAt => + UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto index 7664f9f05a..31c06c68cf 100644 --- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -87,6 +87,29 @@ message ContentArtifactCurrentStateDocument { google.protobuf.Timestamp artifact_updated_at_utc = 40; string tombstone_reason = 41; google.protobuf.Timestamp tombstoned_at_utc = 42; + map labels = 43; +} + +// Actor-scoped current-state replica for one scope + pin_key authority. +// pin_version advances only for successful pointer mutations; state_version +// also exposes persisted rejection observations from the authority actor. +message ContentArtifactPinCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + + string scope_id = 20; + string pin_key = 21; + string pinned_artifact_id = 22; + string pinned_by_principal_id = 23; + string pinned_by_principal_kind = 24; + int64 pin_version = 25; + google.protobuf.Timestamp pin_updated_at_utc = 26; + string last_mutation_id = 27; + string last_mutation_status = 28; + string last_rejection_code = 29; } message WorkOrderArtifactReferenceDocument { diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactCommandServiceTests.cs index 56e8ebd1b3..ac4c7529db 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactCommandServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactCommandServiceTests.cs @@ -38,6 +38,7 @@ public async Task CreateAsync_ShouldUseStableArtifactRevisionAndRuntimeDeliveryI command.FirstRevision.RevisionId.Should().Be(ContentArtifactConventions.BuildRevisionId(artifactId, 1)); command.FirstRevision.RevisionNumber.Should().Be(1); command.FirstRevision.Provenance.ScopeId.Should().Be(ScopeId); + command.Labels.Should().Contain("period", "2026-08-25"); dispatchPort.Envelopes.Select(static envelope => envelope.EnsureRuntime().EnsureDeliveryIdentity().OperationId) .Should().OnlyContain(id => id == first.CommandId); } @@ -220,6 +221,42 @@ public async Task LifecycleCommands_ShouldMapRequesterConcurrencyAndOperationSpe tombstone.ExpectedConcurrencyVersion.Should().Be(11); } + [Fact] + public async Task PinCommands_ShouldDispatchToCanonicalScopeAndPinKeyActor() + { + var bootstrap = new RecordingBootstrap(); + var dispatchPort = new RecordingDispatchPort(); + var service = new ActorDispatchContentArtifactPinCommandService( + bootstrap, + CreateCommandDispatch(dispatchPort)); + var requester = new ContentArtifactPrincipalContract("owner-1", "user"); + + var set = await service.SetAsync( + ScopeId, + "daily-ops-report", + new SetContentArtifactPinRequest("artifact-1", 0, "mutation-1"), + requester); + var clear = await service.ClearAsync( + ScopeId, + "daily-ops-report", + new ClearContentArtifactPinRequest(1, "mutation-2"), + requester); + + bootstrap.ActorIds.Should().OnlyContain(actorId => actorId == + ContentArtifactConventions.BuildPinActorId(ScopeId, "daily-ops-report")); + set.Stage.Should().Be(ContentArtifactCommandStageNames.DispatchAccepted); + clear.Stage.Should().Be(ContentArtifactCommandStageNames.DispatchAccepted); + var setCommand = dispatchPort.Envelopes[0].Payload! + .Unpack(); + setCommand.ArtifactId.Should().Be("artifact-1"); + setCommand.ExpectedPinVersion.Should().Be(0); + setCommand.MutationId.Should().Be("mutation-1"); + var clearCommand = dispatchPort.Envelopes[1].Payload! + .Unpack(); + clearCommand.ExpectedPinVersion.Should().Be(1); + clearCommand.MutationId.Should().Be("mutation-2"); + } + [Theory] [InlineData("text", Aevatar.ContentArtifacts.Abstractions.ContentArtifactKind.Text)] [InlineData("other_content", Aevatar.ContentArtifacts.Abstractions.ContentArtifactKind.OtherContent)] @@ -288,7 +325,8 @@ private static CreateContentArtifactRequest CreateRequest() => Title: "Quarterly report", Classification: "internal", DedupKey: "report-dedup", - FirstRevision: RevisionWrite("report", "revision-1-dedup")); + FirstRevision: RevisionWrite("report", "revision-1-dedup"), + Labels: new Dictionary { ["period"] = "2026-08-25" }); private static ContentArtifactRevisionWriteRequest RevisionWrite( string content, diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs index 3fadc3639d..0f9cdc17ea 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs @@ -54,6 +54,61 @@ public async Task HandleGetRevisionContentAsync_ShouldRequireAuthenticatedPrinci service.ContentRead.Should().BeFalse(); } + [Fact] + public async Task HandleListAsync_ShouldAppendPairedLabelQueryParameters() + { + var service = new RecordingService(); + + var result = await ContentArtifactEndpoints.HandleListAsync( + CreateContext("reader-1"), + ScopeId, + service, + pageSize: null, + pageToken: null, + teamId: null, + kind: null, + lifecycleStatus: null, + workOrderId: null, + runId: null, + labelKey: "period", + labelValue: "2026-08-25", + CancellationToken.None); + + result.Should().BeOfType>(); + service.ListQuery!.LabelKey.Should().Be("period"); + service.ListQuery.LabelValue.Should().Be("2026-08-25"); + } + + [Fact] + public async Task PinHandlers_ShouldReturnCurrentPointerAndAcceptedMutationReceipts() + { + var service = new RecordingPinService(); + + var get = await ContentArtifactEndpoints.HandleGetPinAsync( + CreateContext("owner-1"), ScopeId, "daily-ops-report", service, CancellationToken.None); + var set = await ContentArtifactEndpoints.HandleSetPinAsync( + CreateContext("owner-1"), + ScopeId, + "daily-ops-report", + new SetContentArtifactPinRequest("artifact-1", 0, "mutation-1"), + service, + CancellationToken.None); + var clear = await ContentArtifactEndpoints.HandleClearPinAsync( + CreateContext("owner-1"), + ScopeId, + "daily-ops-report", + new ClearContentArtifactPinRequest(1, "mutation-2"), + service, + CancellationToken.None); + + get.Should().BeOfType>(); + var acceptedSet = set.Should().BeOfType>().Subject; + acceptedSet.Location.Should().Be( + "/api/scopes/scope-1/content-artifact-pins/daily-ops-report"); + clear.Should().BeOfType>(); + service.Requester.Should().Be(new ContentArtifactPrincipalContract("owner-1", "user")); + } + [Fact] public async Task HandleGetRevisionContentAsync_ShouldReturnVerifiedContentWithMediaType() { @@ -239,6 +294,7 @@ private sealed class RecordingService(Exception? exception = null) : IContentArt { public ContentArtifactPrincipalContract? Requester { get; private set; } public bool ContentRead { get; private set; } + public ContentArtifactQueryRequest? ListQuery { get; private set; } public Task CreateAsync(string scopeId, CreateContentArtifactRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default) { @@ -257,7 +313,12 @@ public Task GetRevisionContentAsync(stri content)); } - public Task ListAsync(string scopeId, ContentArtifactQueryRequest query, ContentArtifactPrincipalContract requester, CancellationToken ct = default) => Task.FromResult(Result(new ContentArtifactListResponse(scopeId, []))); + public Task ListAsync(string scopeId, ContentArtifactQueryRequest query, ContentArtifactPrincipalContract requester, CancellationToken ct = default) + { + ListQuery = query; + Requester = requester; + return Task.FromResult(Result(new ContentArtifactListResponse(scopeId, []))); + } public Task GetAsync(string scopeId, string artifactId, ContentArtifactPrincipalContract requester, CancellationToken ct = default) => Task.FromResult(Result(Current())); public Task GetRevisionAsync(string scopeId, string artifactId, string revisionId, ContentArtifactPrincipalContract requester, CancellationToken ct = default) => Task.FromResult(Result(Revision())); public Task GetCurrentRevisionAsync(string scopeId, string artifactId, ContentArtifactPrincipalContract requester, CancellationToken ct = default) => Task.FromResult(Result(Revision())); @@ -295,6 +356,56 @@ private static ContentArtifactCurrentStateResponse Current() => [Revision()], DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); } + private sealed class RecordingPinService : IContentArtifactPinService + { + public ContentArtifactPrincipalContract? Requester { get; private set; } + + public Task GetAsync( + string scopeId, + string pinKey, + CancellationToken ct = default) => + Task.FromResult(new ContentArtifactPinCurrentStateResponse( + scopeId, + pinKey, + "artifact-1", + new ContentArtifactPrincipalContract("owner-1", "user"), + 1, + 1, + DateTimeOffset.UnixEpoch, + "mutation-1", + "succeeded")); + + public Task SetAsync( + string scopeId, + string pinKey, + SetContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default) + { + Requester = requester; + return Receipt(scopeId, pinKey); + } + + public Task ClearAsync( + string scopeId, + string pinKey, + ClearContentArtifactPinRequest request, + ContentArtifactPrincipalContract requester, + CancellationToken ct = default) + { + Requester = requester; + return Receipt(scopeId, pinKey); + } + + private static Task Receipt(string scopeId, string pinKey) => + Task.FromResult(new ContentArtifactPinAcceptedReceipt( + scopeId, + pinKey, + "command-1", + "correlation-1", + ContentArtifactCommandStageNames.DispatchAccepted)); + } + private sealed class TestHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Development; diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactGAgentTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactGAgentTests.cs index 22280563d4..52e85536e7 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactGAgentTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactGAgentTests.cs @@ -37,6 +37,7 @@ public async Task Create_ShouldCommitFirstRevisionAndKeepIdentitiesSeparate() agent.State.TeamId.Should().Be("team-1"); agent.State.WorkOrderId.Should().Be("work-order-1"); agent.State.AccessPolicy.Owner.PrincipalId.Should().Be("owner-1"); + agent.State.Labels.Should().Contain("period", "2026-q3"); agent.State.ConcurrencyVersion.Should().Be(1); agent.State.LifecycleStatus.Should().Be(ContentArtifactLifecycleStatus.Active); agent.State.CurrentRevisionId.Should().Be(ContentArtifactConventions.BuildRevisionId(ArtifactId, 1)); @@ -91,6 +92,22 @@ await act.Should().ThrowAsync() agent.State.Title.Should().Be("Quarterly report"); } + [Fact] + public async Task DuplicateCreate_ShouldTreatLabelsAsImmutableCreationFacts() + { + var agent = await CreateAgentAsync(); + var command = BuildCreate("initial report"); + await agent.HandleCreateAsync(command); + var conflicting = command.Clone(); + conflicting.Labels["period"] = "2026-q4"; + + var act = () => agent.HandleCreateAsync(conflicting); + + await act.Should().ThrowAsync() + .WithMessage("*different request*"); + agent.State.Labels.Should().Contain("period", "2026-q3"); + } + [Fact] public async Task DuplicateCreate_ShouldUseCommittedHashWithoutReopeningBackingContent() { @@ -280,6 +297,7 @@ public async Task AppendAndAdvance_ShouldKeepPriorRevisionImmutableAndUseAdvance var agent = await CreateAgentAsync(); await agent.HandleCreateAsync(BuildCreate("revision one")); var first = agent.State.Revisions[agent.State.CurrentRevisionId].Clone(); + var labels = agent.State.Labels.ToDictionary(); var second = BuildRevision(2, "revision two", "revision-2-dedup", first.RevisionId); await agent.HandleAppendRevisionAsync(new AppendContentArtifactRevision @@ -304,6 +322,7 @@ await agent.HandleAdvanceCurrentRevisionAsync(new AdvanceContentArtifactCurrentR agent.State.CurrentRevisionId.Should().Be(second.RevisionId); agent.State.ConcurrencyVersion.Should().Be(3); + agent.State.Labels.Should().BeEquivalentTo(labels); var stale = () => agent.HandleAdvanceCurrentRevisionAsync(new AdvanceContentArtifactCurrentRevision { @@ -545,6 +564,7 @@ private static CreateContentArtifact BuildCreate(string content) }, WorkOrderId = "work-order-1", ExpectedConcurrencyVersion = 0, + Labels = { ["period"] = "2026-q3" }, }; command.FirstRevision = BuildRevision(1, content, "revision-1-dedup"); return command; diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinGAgentTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinGAgentTests.cs new file mode 100644 index 0000000000..12a8c0c6d8 --- /dev/null +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinGAgentTests.cs @@ -0,0 +1,133 @@ +using System.Reflection; +using Aevatar.ContentArtifacts.Abstractions; +using Aevatar.Foundation.Abstractions.Hooks; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Aevatar.Foundation.Runtime.Persistence; +using Aevatar.GAgents.ContentArtifacts; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Studio.Tests.ContentArtifacts; + +public sealed class ContentArtifactPinGAgentTests +{ + private const string ScopeId = "scope-1"; + private const string PinKey = "daily-ops-report"; + private static readonly string ActorId = ContentArtifactConventions.BuildPinActorId(ScopeId, PinKey); + private static readonly MethodInfo SetIdMethod = typeof(GAgentBase) + .GetMethod("SetId", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("GAgentBase.SetId was not found."); + + [Fact] + public async Task SetReplaceAndClear_ShouldMaintainOnePointerAndMonotonicPinVersion() + { + var agent = await CreateAgentAsync(); + + await agent.HandleSetAsync(Set("artifact-a", 0, "mutation-1")); + await agent.HandleSetAsync(Set("artifact-b", 1, "mutation-2")); + await agent.HandleClearAsync(Clear(2, "mutation-3")); + + agent.State.ScopeId.Should().Be(ScopeId); + agent.State.PinKey.Should().Be(PinKey); + agent.State.PinnedArtifactId.Should().BeEmpty(); + agent.State.PinnedBy.Should().BeNull(); + agent.State.PinVersion.Should().Be(3); + agent.State.LastMutationId.Should().Be("mutation-3"); + agent.State.LastMutationStatus.Should().Be(ContentArtifactPinMutationStatus.Succeeded); + } + + [Fact] + public async Task MutationReplay_ShouldBeIdempotentAndRejectDifferentFacts() + { + var agent = await CreateAgentAsync(); + var command = Set("artifact-a", 0, "mutation-1"); + + await agent.HandleSetAsync(command); + await agent.HandleSetAsync(command.Clone()); + + agent.State.PinVersion.Should().Be(1); + var conflicting = command.Clone(); + conflicting.ArtifactId = "artifact-b"; + var act = () => agent.HandleSetAsync(conflicting); + await act.Should().ThrowAsync() + .WithMessage("*mutation_id*already used for different facts*"); + agent.State.PinnedArtifactId.Should().Be("artifact-a"); + } + + [Fact] + public async Task CasConflict_ShouldPersistRejectionWithoutChangingPointerAndReplayDeterministically() + { + var store = new InMemoryEventStore(); + var agent = await CreateAgentAsync(store); + await agent.HandleSetAsync(Set("artifact-a", 0, "mutation-1")); + var stale = Set("artifact-b", 0, "mutation-stale"); + + await agent.HandleSetAsync(stale); + await agent.HandleSetAsync(stale.Clone()); + + agent.State.PinnedArtifactId.Should().Be("artifact-a"); + agent.State.PinVersion.Should().Be(1); + agent.State.LastMutationId.Should().Be("mutation-stale"); + agent.State.LastMutationStatus.Should().Be(ContentArtifactPinMutationStatus.Rejected); + agent.State.LastRejectionCode.Should().Be(ContentArtifactPinRejectionCode.PinVersionConflict); + + var recovered = await CreateAgentAsync(store); + recovered.State.Should().BeEquivalentTo(agent.State); + } + + [Fact] + public async Task PinKeyAndActorAddress_ShouldUseCanonicalLabelKeyRules() + { + var invalidKey = () => ContentArtifactConventions.BuildPinActorId(ScopeId, "aevatar.primary"); + invalidKey.Should().Throw().WithMessage("*reserved*"); + + var wrongAddress = await CreateAgentAsync(actorId: "content-artifact-pin:scope-1:other-key"); + var act = () => wrongAddress.HandleSetAsync(Set("artifact-a", 0, "mutation-1")); + await act.Should().ThrowAsync().WithMessage("*canonical identity*"); + } + + private static async Task CreateAgentAsync( + InMemoryEventStore? eventStore = null, + string? actorId = null) + { + var agent = new ContentArtifactPinGAgent + { + EventSourcingBehaviorFactory = new DefaultEventSourcingBehaviorFactory( + eventStore ?? new InMemoryEventStore()), + Services = new ServiceCollection() + .AddSingleton>([]) + .BuildServiceProvider(), + }; + SetIdMethod.Invoke(agent, [actorId ?? ActorId]); + await agent.ActivateAsync(); + return agent; + } + + private static SetContentArtifactPinCommand Set( + string artifactId, + long expectedPinVersion, + string mutationId) => + new() + { + ScopeId = ScopeId, + PinKey = PinKey, + ArtifactId = artifactId, + RequestedBy = Principal(), + ExpectedPinVersion = expectedPinVersion, + MutationId = mutationId, + }; + + private static ClearContentArtifactPinCommand Clear(long expectedPinVersion, string mutationId) => + new() + { + ScopeId = ScopeId, + PinKey = PinKey, + RequestedBy = Principal(), + ExpectedPinVersion = expectedPinVersion, + MutationId = mutationId, + }; + + private static ContentArtifactPrincipal Principal() => + new() { PrincipalId = "owner-1", PrincipalKind = "user" }; +} diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs new file mode 100644 index 0000000000..79487de046 --- /dev/null +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs @@ -0,0 +1,234 @@ +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Application.Studio.Services; +using FluentAssertions; + +namespace Aevatar.Studio.Tests.ContentArtifacts; + +public sealed class ContentArtifactPinServiceTests +{ + [Fact] + public async Task SetAsync_ShouldValidateTargetThenDispatchCanonicalPinMutation() + { + var artifactQuery = new RecordingArtifactQueryPort(Artifact()); + var commandPort = new RecordingPinCommandPort(); + var service = new ContentArtifactPinService( + artifactQuery, + new RecordingPinQueryPort(current: null), + commandPort); + + var receipt = await service.SetAsync( + " scope-1 ", + " daily-ops-report ", + new SetContentArtifactPinRequest(" artifact-1 ", 0, " mutation-1 "), + Principal("owner-1")); + + receipt.PinKey.Should().Be("daily-ops-report"); + commandPort.SetRequest.Should().Be(new SetContentArtifactPinRequest("artifact-1", 0, "mutation-1")); + commandPort.ScopeId.Should().Be("scope-1"); + commandPort.PinKey.Should().Be("daily-ops-report"); + } + + [Theory] + [InlineData("missing")] + [InlineData("tombstoned")] + [InlineData("other-owner")] + public async Task SetAsync_ShouldRejectUnavailableOrNonOwnedTarget(string scenario) + { + var artifact = scenario switch + { + "missing" => null, + "tombstoned" => Artifact() with { LifecycleStatus = ContentArtifactLifecycleStatusNames.Tombstoned }, + "other-owner" => Artifact() with { Owner = Principal("owner-2") }, + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null), + }; + var commandPort = new RecordingPinCommandPort(); + var service = new ContentArtifactPinService( + new RecordingArtifactQueryPort(artifact), + new RecordingPinQueryPort(current: null), + commandPort); + + var act = () => service.SetAsync( + "scope-1", + "daily-ops-report", + new SetContentArtifactPinRequest("artifact-1", 0, "mutation-1"), + Principal("owner-1")); + + await act.Should().ThrowAsync(); + commandPort.SetRequest.Should().BeNull(); + } + + [Fact] + public async Task SetAsync_ShouldDispatchStaleVersionForActorOwnedPersistedRejection() + { + var commandPort = new RecordingPinCommandPort(); + var service = new ContentArtifactPinService( + new RecordingArtifactQueryPort(Artifact()), + new RecordingPinQueryPort(Pin(version: 3)), + commandPort); + + await service.SetAsync( + "scope-1", + "daily-ops-report", + new SetContentArtifactPinRequest("artifact-1", 2, "mutation-2"), + Principal("owner-1")); + + commandPort.SetRequest.Should().Be( + new SetContentArtifactPinRequest("artifact-1", 2, "mutation-2")); + } + + [Fact] + public async Task ClearAsync_ShouldUsePinnedByAuthorityEvenWhenTargetArtifactIsUnavailable() + { + var artifactQuery = new RecordingArtifactQueryPort(current: null); + var commandPort = new RecordingPinCommandPort(); + var service = new ContentArtifactPinService( + artifactQuery, + new RecordingPinQueryPort(Pin(version: 4)), + commandPort); + + await service.ClearAsync( + "scope-1", + "daily-ops-report", + new ClearContentArtifactPinRequest(4, "mutation-clear"), + Principal("owner-1")); + + artifactQuery.GetCallCount.Should().Be(0); + commandPort.ClearRequest.Should().Be(new ClearContentArtifactPinRequest(4, "mutation-clear")); + } + + [Fact] + public async Task ClearAsync_ShouldDispatchStaleVersionForActorOwnedPersistedRejection() + { + var commandPort = new RecordingPinCommandPort(); + var service = new ContentArtifactPinService( + new RecordingArtifactQueryPort(current: null), + new RecordingPinQueryPort(Pin(version: 4)), + commandPort); + + await service.ClearAsync( + "scope-1", + "daily-ops-report", + new ClearContentArtifactPinRequest(3, "mutation-clear"), + Principal("owner-1")); + + commandPort.ClearRequest.Should().Be(new ClearContentArtifactPinRequest(3, "mutation-clear")); + } + + [Fact] + public async Task ClearAndGet_ShouldHideAbsentOrOtherOwnersPin() + { + var otherOwnerService = new ContentArtifactPinService( + new RecordingArtifactQueryPort(current: null), + new RecordingPinQueryPort(Pin(version: 1)), + new RecordingPinCommandPort()); + var denied = () => otherOwnerService.ClearAsync( + "scope-1", + "daily-ops-report", + new ClearContentArtifactPinRequest(1, "mutation-clear"), + Principal("owner-2")); + await denied.Should().ThrowAsync(); + + var absentService = new ContentArtifactPinService( + new RecordingArtifactQueryPort(current: null), + new RecordingPinQueryPort(Pin(version: 2) with { PinnedArtifactId = null, PinnedBy = null }), + new RecordingPinCommandPort()); + var missing = () => absentService.GetAsync("scope-1", "daily-ops-report"); + await missing.Should().ThrowAsync(); + } + + private static ContentArtifactCurrentStateResponse Artifact() => + new( + "artifact-1", + "scope-1", + null, + "markdown", + "Daily report", + "internal", + ContentArtifactLifecycleStatusNames.Active, + null, + 1, + 1, + Principal("owner-1"), + [], + [], + null, + null, + [], + DateTimeOffset.UnixEpoch, + DateTimeOffset.UnixEpoch); + + private static ContentArtifactPinCurrentStateResponse Pin(long version) => + new( + "scope-1", + "daily-ops-report", + "artifact-1", + Principal("owner-1"), + version, + version, + DateTimeOffset.UnixEpoch, + $"mutation-{version}", + "succeeded"); + + private static ContentArtifactPrincipalContract Principal(string id) => new(id, "user"); + + private sealed class RecordingArtifactQueryPort(ContentArtifactCurrentStateResponse? current) + : IContentArtifactQueryPort + { + public int GetCallCount { get; private set; } + + public Task GetAsync( + string scopeId, + string artifactId, + CancellationToken ct = default) + { + GetCallCount++; + return Task.FromResult(current); + } + + public Task ListAsync(string scopeId, string requesterPrincipalId, ContentArtifactQueryRequest query, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetByDedupKeyAsync(string scopeId, string dedupKey, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetRevisionContentAsync(string scopeId, string artifactId, string revisionId, ContentArtifactPrincipalContract requester, CancellationToken ct = default) => throw new NotSupportedException(); + } + + private sealed class RecordingPinQueryPort(ContentArtifactPinCurrentStateResponse? current) + : IContentArtifactPinQueryPort + { + public Task GetAsync( + string scopeId, + string pinKey, + CancellationToken ct = default) => Task.FromResult(current); + } + + private sealed class RecordingPinCommandPort : IContentArtifactPinCommandPort + { + public string? ScopeId { get; private set; } + public string? PinKey { get; private set; } + public SetContentArtifactPinRequest? SetRequest { get; private set; } + public ClearContentArtifactPinRequest? ClearRequest { get; private set; } + + public Task SetAsync(string scopeId, string pinKey, SetContentArtifactPinRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default) + { + ScopeId = scopeId; + PinKey = pinKey; + SetRequest = request; + return Receipt(scopeId, pinKey); + } + + public Task ClearAsync(string scopeId, string pinKey, ClearContentArtifactPinRequest request, ContentArtifactPrincipalContract requester, CancellationToken ct = default) + { + ScopeId = scopeId; + PinKey = pinKey; + ClearRequest = request; + return Receipt(scopeId, pinKey); + } + + private static Task Receipt(string scopeId, string pinKey) => + Task.FromResult(new ContentArtifactPinAcceptedReceipt( + scopeId, + pinKey, + "command-1", + "correlation-1", + ContentArtifactCommandStageNames.DispatchAccepted)); + } +} diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs index 2bd562862c..a48babea1c 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs @@ -1,6 +1,10 @@ using System.Security.Cryptography; +using System.Net; +using System.Text; using Aevatar.ContentArtifacts.Abstractions; using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.CQRS.Projection.Runtime.Abstractions; using Aevatar.CQRS.Projection.Stores.Abstractions; @@ -9,6 +13,7 @@ using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Hosting; using Aevatar.Studio.Projection.DependencyInjection; +using Aevatar.Studio.Projection.Metadata; using Aevatar.Studio.Projection.Orchestration; using Aevatar.Studio.Projection.Projectors; using Aevatar.Studio.Projection.QueryPorts; @@ -41,6 +46,8 @@ public void ReadModelProviders_ShouldRegisterContentArtifactStore() .Should().BeOfType>(); provider.GetRequiredService>() .Should().BeOfType>(); + provider.GetRequiredService>() + .Should().BeOfType>(); } [Fact] @@ -71,6 +78,7 @@ await projector.ProjectAsync( document.OwnerPrincipalId.Should().Be("owner-1"); document.CurrentRevisionId.Should().Be("revision-2"); document.ConcurrencyVersion.Should().Be(3); + document.Labels.Should().Contain("period", "2026-08-25"); document.ArtifactUpdatedAtUtc.ToDateTimeOffset().Should().Be(updatedAt); document.Revisions.Select(static revision => revision.RevisionNumber).Should().Equal(1, 2); document.Revisions[0].InlineContent.ToStringUtf8().Should().Be("revision one"); @@ -96,7 +104,9 @@ public async Task QueryPort_ShouldFilterByScopeReadablePrincipalAndResolveExactR new ContentArtifactQueryRequest( TeamId: "team-1", Kind: "markdown", - RunId: "run-1")); + RunId: "run-1", + LabelKey: "period", + LabelValue: "2026-08-25")); var current = await queryPort.GetAsync(ScopeId, ArtifactId); list.Artifacts.Should().ContainSingle(); @@ -110,6 +120,93 @@ public async Task QueryPort_ShouldFilterByScopeReadablePrincipalAndResolveExactR reader.LastQuery.Filters.Should().Contain(filter => filter.FieldPath == "team_id"); reader.LastQuery.Filters.Should().Contain(filter => filter.FieldPath == "kind"); reader.LastQuery.Filters.Should().Contain(filter => filter.FieldPath == "provenance_run_ids"); + reader.LastQuery.Filters.Should().Contain(filter => filter.FieldPath == "labels.period"); + } + + [Fact] + public async Task ListAsync_ShouldFilterLabelsByExactKeyAndValueInMemory() + { + var store = new InMemoryProjectionDocumentStore( + keySelector: document => document.Id); + var matching = BuildListDocument("matching", "caller-1"); + matching.Labels["period"] = "2026-08-25"; + var wrongValue = BuildListDocument("wrong-value", "caller-1"); + wrongValue.Labels["period"] = "2026-08-24"; + var wrongKey = BuildListDocument("wrong-key", "caller-1"); + wrongKey.Labels["cycle"] = "2026-08-25"; + await store.UpsertAsync(matching); + await store.UpsertAsync(wrongValue); + await store.UpsertAsync(wrongKey); + + var result = await new ProjectionContentArtifactQueryPort(store).ListAsync( + ScopeId, + "caller-1", + new ContentArtifactQueryRequest(LabelKey: "period", LabelValue: "2026-08-25")); + + result.Artifacts.Should().ContainSingle().Which.ArtifactId.Should().Be("matching"); + } + + [Fact] + public async Task ListAsync_ShouldUseFlattenedElasticsearchMapAndExactLabelPath() + { + var metadata = new ContentArtifactCurrentStateDocumentMetadataProvider().Metadata; + var properties = metadata.Mappings["properties"].Should() + .BeAssignableTo>().Subject; + var labels = properties["labels"].Should() + .BeAssignableTo>().Subject; + labels.Should().Contain("type", "flattened"); + + var handler = new RecordingElasticsearchHandler(); + using var store = new ElasticsearchProjectionDocumentStore( + new ElasticsearchProjectionDocumentStoreOptions + { + Endpoints = ["http://localhost:9200"], + AutoCreateIndex = false, + }, + metadata, + document => document.Id, + httpMessageHandler: handler); + await new ProjectionContentArtifactQueryPort(store).ListAsync( + ScopeId, + "caller-1", + new ContentArtifactQueryRequest(LabelKey: "period", LabelValue: "2026-08-25")); + + handler.Body.Should().Contain("\"labels.period\":\"2026-08-25\""); + handler.Body.Should().NotContain("labels.period.keyword"); + } + + [Fact] + public async Task PinCurrentState_ShouldExposeActorPinVersionAndCommittedStateVersion() + { + var observedAt = DateTimeOffset.Parse("2026-08-25T10:00:00Z"); + var document = ContentArtifactPinCurrentStateProjector.ToDocument( + ContentArtifactConventions.BuildPinActorId(ScopeId, "daily-ops-report"), + new StateEvent { Version = 9, EventId = "event-9" }, + new ContentArtifactPinState + { + ScopeId = ScopeId, + PinKey = "daily-ops-report", + PinnedArtifactId = ArtifactId, + PinnedBy = new ContentArtifactPrincipal + { + PrincipalId = "owner-1", + PrincipalKind = "user", + }, + PinVersion = 3, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(observedAt), + LastMutationId = "mutation-3", + LastMutationStatus = ContentArtifactPinMutationStatus.Succeeded, + }, + observedAt); + + var current = await new ProjectionContentArtifactPinQueryPort( + new RecordingPinDocumentReader(document)).GetAsync(ScopeId, "daily-ops-report"); + + current.Should().NotBeNull(); + current!.PinnedArtifactId.Should().Be(ArtifactId); + current.PinVersion.Should().Be(3); + current.StateVersion.Should().Be(9); + current.PinnedBy!.PrincipalId.Should().Be("owner-1"); } [Fact] @@ -492,6 +589,7 @@ private static ContentArtifactState BuildState(DateTimeOffset updatedAt) CreatedAtUtc = Timestamp.FromDateTimeOffset(updatedAt.AddHours(-1)), UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAt), }; + state.Labels["period"] = "2026-08-25"; state.Revisions["revision-1"] = new ContentArtifactRevision { RevisionId = "revision-1", @@ -646,6 +744,37 @@ public Task> } } + private sealed class RecordingPinDocumentReader(ContentArtifactPinCurrentStateDocument document) + : IProjectionDocumentReader + { + public Task GetAsync( + string key, + CancellationToken ct = default) => + Task.FromResult(key == document.Id ? document : null); + + public Task> QueryAsync( + ProjectionDocumentQuery query, + CancellationToken ct = default) => throw new NotSupportedException(); + } + + private sealed class RecordingElasticsearchHandler : HttpMessageHandler + { + public string Body { get; private set; } = string.Empty; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Body = request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"hits\":{\"hits\":[]}}", Encoding.UTF8, "application/json"), + }; + } + } + private sealed class FixedProjectionClock(DateTimeOffset utcNow) : IProjectionClock { public DateTimeOffset UtcNow { get; } = utcNow; diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactServiceTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactServiceTests.cs index 024c555344..e4b649dbf7 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactServiceTests.cs @@ -3,6 +3,7 @@ using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.GAgentService.Abstractions.Queries; +using Aevatar.GAgents.ContentArtifacts; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Application.Studio.Services; @@ -30,6 +31,7 @@ public async Task CreateAsync_ShouldValidateActiveTeamAndNormalizeExecutionProve commandPort.CreateRequest.FirstRevision.Provenance.ScopeId.Should().Be("scope-1"); commandPort.CreateRequest.FirstRevision.Provenance.TeamId.Should().Be("team-1"); commandPort.CreateRequest.FirstRevision.ContentHash.Should().Be(ContentHash("report")); + commandPort.CreateRequest.Labels.Should().Contain("period", "2026-08-25"); } [Fact] @@ -72,6 +74,64 @@ await service.CreateAsync( commandPort.CreateRequest.FirstRevision.Provenance.TeamId.Should().BeNull(); } + [Fact] + public async Task CreateAsync_ShouldRejectInvalidLabels() + { + var invalidLabels = new IReadOnlyDictionary[] + { + Enumerable.Range(0, ContentArtifactConventions.MaxLabelCount + 1) + .ToDictionary(index => $"key-{index}", _ => "value"), + new Dictionary { ["Uppercase"] = "value" }, + new Dictionary { ["aevatar.period"] = "value" }, + new Dictionary { ["period"] = "line one\nline two" }, + new Dictionary + { + ["period"] = new string('x', ContentArtifactConventions.MaxLabelValueCharacters + 1), + }, + }; + + foreach (var labels in invalidLabels) + { + var service = CreateService(commandPort: new RecordingCommandPort()); + var act = () => service.CreateAsync( + "scope-1", + CreateRequest() with { Labels = labels }, + Principal("owner-1")); + await act.Should().ThrowAsync(); + } + } + + [Theory] + [InlineData("period", null)] + [InlineData(null, "2026-08-25")] + public async Task ListAsync_ShouldRejectHalfSpecifiedLabelFilter(string? labelKey, string? labelValue) + { + var service = CreateService(); + + var act = () => service.ListAsync( + "scope-1", + new ContentArtifactQueryRequest(LabelKey: labelKey, LabelValue: labelValue), + Principal("owner-1")); + + await act.Should().ThrowAsync() + .WithMessage("*labelKey and labelValue*provided together*"); + } + + [Fact] + public async Task ListAsync_ShouldNormalizePairedLabelFilter() + { + var queryPort = new RecordingQueryPort(BuildCurrentState()); + var service = CreateService(queryPort: queryPort); + + await service.ListAsync( + "scope-1", + new ContentArtifactQueryRequest(LabelKey: " period ", LabelValue: " 2026-08-25 "), + Principal("owner-1")); + + queryPort.LastListQuery!.LabelKey.Should().Be("period"); + queryPort.LastListQuery.LabelValue.Should().Be("2026-08-25"); + } + [Fact] public async Task CreateAsync_ShouldExposeOnlyDedupKeyOccupancyForAnotherOwner() { @@ -352,7 +412,8 @@ private static CreateContentArtifactRequest CreateRequest(string? teamId = " tea FirstRevision: RevisionWrite("report", "revision-1-dedup"), AccessPolicy: new([" reader-1 "], [" writer-1 "]), RetentionPolicy: new("retain-365-days"), - WorkOrderId: "work-order-1"); + WorkOrderId: "work-order-1", + Labels: new Dictionary { ["period"] = "2026-08-25" }); private static ContentArtifactRevisionWriteRequest RevisionWrite( string content, @@ -584,11 +645,15 @@ private static Task Receipt() => private sealed class RecordingQueryPort(ContentArtifactCurrentStateResponse? current) : IContentArtifactQueryPort { public int ContentReadCount { get; private set; } + public ContentArtifactQueryRequest? LastListQuery { get; private set; } - public Task ListAsync(string scopeId, string ownerPrincipalId, ContentArtifactQueryRequest query, CancellationToken ct = default) => - Task.FromResult(new ContentArtifactListResponse( + public Task ListAsync(string scopeId, string ownerPrincipalId, ContentArtifactQueryRequest query, CancellationToken ct = default) + { + LastListQuery = query; + return Task.FromResult(new ContentArtifactListResponse( scopeId, current == null ? [] : [current])); + } public Task GetAsync(string scopeId, string artifactId, CancellationToken ct = default) => Task.FromResult(current); From dac656491773362c8615ba80b5fc5adac8dcee9a Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 26 Aug 2026 03:26:25 +0800 Subject: [PATCH 2/2] Fix review round 1 on PR #3538 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/canon/content-artifacts.md | 17 ++-- .../Contracts/ContentArtifactContracts.cs | 3 +- .../Services/ContentArtifactPinService.cs | 26 ++++- .../Endpoints/ContentArtifactEndpoints.cs | 19 ++++ ...ContentArtifactPinCurrentStateProjector.cs | 5 + .../ProjectionContentArtifactPinQueryPort.cs | 7 +- .../studio_projection_readmodels.proto | 2 + .../ContentArtifactEndpointsTests.cs | 99 +++++++++++++++++-- .../ContentArtifactPinServiceTests.cs | 68 +++++++++++-- .../ContentArtifactProjectionTests.cs | 42 ++++++++ 10 files changed, 260 insertions(+), 28 deletions(-) diff --git a/docs/canon/content-artifacts.md b/docs/canon/content-artifacts.md index e62a459f66..c987a5ac9f 100644 --- a/docs/canon/content-artifacts.md +++ b/docs/canon/content-artifacts.md @@ -61,13 +61,16 @@ four-value ContentArtifact kind. Because every mutation for the same key reaches one actor, set atomically replaces the prior artifact and at most one artifact is pinned for that key. -Set requires an ACTIVE target in the same Scope owned by the caller. Clear is -authorized from the committed `pinnedBy` fact so a stale or unavailable target -does not prevent explicit cleanup. The actor owns `pinVersion` CAS and -`mutationId` idempotency. Successful set and clear advance `pinVersion`; a CAS -conflict is persisted as a rejected mutation without changing the pointer or -`pinVersion`. The actor current-state read model exposes both authoritative -`pinVersion` and committed projection `stateVersion`. +Set requires an ACTIVE target in the same Scope owned by the caller. A new clear +is authorized from the committed `pinnedBy` fact so a stale or unavailable +target does not prevent explicit cleanup. After clear removes that pointer, only +the same `mutationId` from the committed last mutation requester may pass the +application boundary as a replay candidate; the actor still verifies the full +mutation hash. The actor owns `pinVersion` CAS and `mutationId` idempotency. +Successful set and clear advance `pinVersion`; a CAS conflict is persisted as a +rejected mutation without changing the pointer or `pinVersion`. The actor +current-state read model exposes both authoritative `pinVersion` and committed +projection `stateVersion`. Artifact lifecycle does not cascade into the pin actor. If a pinned artifact is later tombstoned or otherwise unavailable, consumers report diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs index bbf271c242..bfc1d25f0d 100644 --- a/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Contracts/ContentArtifactContracts.cs @@ -210,7 +210,8 @@ public sealed record ContentArtifactPinCurrentStateResponse( DateTimeOffset UpdatedAtUtc, string LastMutationId, string LastMutationStatus, - string? LastRejectionCode = null); + string? LastRejectionCode = null, + ContentArtifactPrincipalContract? LastMutationRequestedBy = null); public sealed record ContentArtifactPinAcceptedReceipt( string ScopeId, diff --git a/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs b/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs index 0e76495943..334a3101b7 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/ContentArtifactPinService.cs @@ -27,7 +27,10 @@ public async Task GetAsync( { var (normalizedScopeId, normalizedPinKey) = NormalizeIdentity(scopeId, pinKey); var current = await GetCurrentAsync(normalizedScopeId, normalizedPinKey, ct); - if (current == null || string.IsNullOrWhiteSpace(current.PinnedArtifactId)) + // Fix (review round 1, F1): + // Empty pointers hid committed pin_version and last-mutation observations behind 404. + // Any existing current-state document is now returned; only a never-mutated pin is absent. + if (current == null) throw new ContentArtifactPinNotFoundException(normalizedScopeId, normalizedPinKey); return current; } @@ -79,10 +82,10 @@ public async Task ClearAsync( var mutationId = ContentArtifactConventions.NormalizeRequired(request.MutationId, nameof(request.MutationId)); ValidateExpectedVersion(request.ExpectedPinVersion); var current = await GetCurrentAsync(normalizedScopeId, normalizedPinKey, ct); - if (current == null || - string.IsNullOrWhiteSpace(current.PinnedArtifactId) || - current.PinnedBy == null || - !PrincipalEquals(current.PinnedBy, normalizedRequester)) + // Fix (review round 1, F1): + // Successful clear removed pinned_by, so an identical mutation_id replay never reached the actor. + // Live pointers use pinned_by; empty pointers allow only the last requester's exact mutation replay. + if (current == null || !CanClear(current, normalizedRequester, mutationId)) { throw new ContentArtifactPinNotFoundException(normalizedScopeId, normalizedPinKey); } @@ -107,6 +110,19 @@ public async Task ClearAsync( : null; } + private static bool CanClear( + ContentArtifactPinCurrentStateResponse current, + ContentArtifactPrincipalContract requester, + string mutationId) + { + if (!string.IsNullOrWhiteSpace(current.PinnedArtifactId)) + return current.PinnedBy != null && PrincipalEquals(current.PinnedBy, requester); + + return string.Equals(current.LastMutationId, mutationId, StringComparison.Ordinal) && + current.LastMutationRequestedBy != null && + PrincipalEquals(current.LastMutationRequestedBy, requester); + } + private static (string ScopeId, string PinKey) NormalizeIdentity(string scopeId, string pinKey) => (ContentArtifactConventions.NormalizeScopeId(scopeId), ContentArtifactConventions.NormalizeLabelKey(pinKey, nameof(pinKey))); diff --git a/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs index 298fcd1211..ee55081f31 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/ContentArtifactEndpoints.cs @@ -93,6 +93,13 @@ internal static async Task HandleListAsync( principal, ct)); } + // Fix (review round 1, F2): + // Illegal label keys surfaced from normalization as ArgumentException and became HTTP 500. + // Map that input-validation exception to the same 400 query contract as other invalid filters. + catch (ArgumentException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_QUERY", ex.Message); + } catch (InvalidOperationException ex) { return BadRequest("INVALID_CONTENT_ARTIFACT_QUERY", ex.Message); @@ -279,6 +286,10 @@ internal static async Task HandleGetPinAsync( { return PinNotFound(ex.Message); } + catch (ArgumentException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_QUERY", ex.Message); + } catch (InvalidOperationException ex) { return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_QUERY", ex.Message); @@ -307,6 +318,10 @@ internal static async Task HandleSetPinAsync( { return NotFound(ex.Message); } + catch (ArgumentException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_COMMAND", ex.Message); + } catch (InvalidOperationException ex) { return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_COMMAND", ex.Message); @@ -332,6 +347,10 @@ internal static async Task HandleClearPinAsync( { return PinNotFound(ex.Message); } + catch (ArgumentException ex) + { + return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_COMMAND", ex.Message); + } catch (InvalidOperationException ex) { return BadRequest("INVALID_CONTENT_ARTIFACT_PIN_COMMAND", ex.Message); diff --git a/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs index ae4f554339..7559e79e34 100644 --- a/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs +++ b/src/Aevatar.Studio.Projection/Projectors/ContentArtifactPinCurrentStateProjector.cs @@ -79,6 +79,11 @@ public static ContentArtifactPinCurrentStateDocument ToDocument( LastMutationId = state.LastMutationId, LastMutationStatus = ToWireName(state.LastMutationStatus), LastRejectionCode = ToWireName(state.LastRejectionCode), + // Fix (review round 1, F1): + // Clear removes pinned_by but its mutation replay still requires requester authorization. + // Materialize the actor's committed last requester so the application can authorize that replay. + LastMutationRequestedByPrincipalId = state.LastMutationRequestedBy?.PrincipalId ?? string.Empty, + LastMutationRequestedByPrincipalKind = state.LastMutationRequestedBy?.PrincipalKind ?? string.Empty, }; } diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs index c4dfe69ae3..5cea62107b 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionContentArtifactPinQueryPort.cs @@ -47,7 +47,12 @@ public ProjectionContentArtifactPinQueryPort( document.PinUpdatedAtUtc?.ToDateTimeOffset() ?? DateTimeOffset.MinValue, document.LastMutationId, document.LastMutationStatus, - NormalizeOptional(document.LastRejectionCode)); + NormalizeOptional(document.LastRejectionCode), + string.IsNullOrWhiteSpace(document.LastMutationRequestedByPrincipalId) + ? null + : new ContentArtifactPrincipalContract( + document.LastMutationRequestedByPrincipalId, + document.LastMutationRequestedByPrincipalKind)); } private static string? NormalizeOptional(string? value) => diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto index 31c06c68cf..afed1b2acb 100644 --- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -110,6 +110,8 @@ message ContentArtifactPinCurrentStateDocument { string last_mutation_id = 27; string last_mutation_status = 28; string last_rejection_code = 29; + string last_mutation_requested_by_principal_id = 30; + string last_mutation_requested_by_principal_kind = 31; } message WorkOrderArtifactReferenceDocument { diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs index 0f9cdc17ea..4619dcddc2 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactEndpointsTests.cs @@ -79,6 +79,29 @@ public async Task HandleListAsync_ShouldAppendPairedLabelQueryParameters() service.ListQuery.LabelValue.Should().Be("2026-08-25"); } + [Fact] + public async Task HandleListAsync_ShouldMapIllegalLabelKeyTo400() + { + var service = new RecordingService(new ArgumentException("labelKey is invalid.")); + + var result = await ContentArtifactEndpoints.HandleListAsync( + CreateContext("reader-1"), + ScopeId, + service, + pageSize: null, + pageToken: null, + teamId: null, + kind: null, + lifecycleStatus: null, + workOrderId: null, + runId: null, + labelKey: "Uppercase", + labelValue: "value", + CancellationToken.None); + + StatusCode(result).Should().Be(StatusCodes.Status400BadRequest); + } + [Fact] public async Task PinHandlers_ShouldReturnCurrentPointerAndAcceptedMutationReceipts() { @@ -109,6 +132,57 @@ public async Task PinHandlers_ShouldReturnCurrentPointerAndAcceptedMutationRecei service.Requester.Should().Be(new ContentArtifactPrincipalContract("owner-1", "user")); } + [Fact] + public async Task HandleGetPinAsync_ShouldReturnClearedPinDocument() + { + var cleared = new ContentArtifactPinCurrentStateResponse( + ScopeId, + "daily-ops-report", + null, + null, + 2, + 5, + DateTimeOffset.UnixEpoch, + "mutation-clear", + "succeeded", + LastMutationRequestedBy: new ContentArtifactPrincipalContract("owner-1", "user")); + var service = new RecordingPinService(current: cleared); + + var result = await ContentArtifactEndpoints.HandleGetPinAsync( + CreateContext("owner-1"), ScopeId, "daily-ops-report", service, CancellationToken.None); + + var response = result.Should().BeOfType>() + .Which.Value!; + response.PinnedArtifactId.Should().BeNull(); + response.PinVersion.Should().Be(2); + response.StateVersion.Should().Be(5); + response.LastMutationStatus.Should().Be("succeeded"); + } + + [Theory] + [InlineData("get")] + [InlineData("set")] + [InlineData("clear")] + public async Task PinHandlers_ShouldMapIllegalPinKeyTo400(string operation) + { + var service = new RecordingPinService(new ArgumentException("pinKey is invalid.")); + + var result = operation switch + { + "get" => await ContentArtifactEndpoints.HandleGetPinAsync( + CreateContext("owner-1"), ScopeId, "Uppercase", service, CancellationToken.None), + "set" => await ContentArtifactEndpoints.HandleSetPinAsync( + CreateContext("owner-1"), ScopeId, "Uppercase", + new SetContentArtifactPinRequest("artifact-1", 0, "mutation-1"), service, CancellationToken.None), + "clear" => await ContentArtifactEndpoints.HandleClearPinAsync( + CreateContext("owner-1"), ScopeId, "Uppercase", + new ClearContentArtifactPinRequest(1, "mutation-2"), service, CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null), + }; + + StatusCode(result).Should().Be(StatusCodes.Status400BadRequest); + } + [Fact] public async Task HandleGetRevisionContentAsync_ShouldReturnVerifiedContentWithMediaType() { @@ -356,7 +430,9 @@ private static ContentArtifactCurrentStateResponse Current() => [Revision()], DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); } - private sealed class RecordingPinService : IContentArtifactPinService + private sealed class RecordingPinService( + Exception? exception = null, + ContentArtifactPinCurrentStateResponse? current = null) : IContentArtifactPinService { public ContentArtifactPrincipalContract? Requester { get; private set; } @@ -364,7 +440,7 @@ public Task GetAsync( string scopeId, string pinKey, CancellationToken ct = default) => - Task.FromResult(new ContentArtifactPinCurrentStateResponse( + Task.FromResult(Result(current ?? new ContentArtifactPinCurrentStateResponse( scopeId, pinKey, "artifact-1", @@ -373,7 +449,7 @@ public Task GetAsync( 1, DateTimeOffset.UnixEpoch, "mutation-1", - "succeeded")); + "succeeded"))); public Task SetAsync( string scopeId, @@ -383,7 +459,7 @@ public Task SetAsync( CancellationToken ct = default) { Requester = requester; - return Receipt(scopeId, pinKey); + return Task.FromResult(Result(Receipt(scopeId, pinKey))); } public Task ClearAsync( @@ -394,16 +470,23 @@ public Task ClearAsync( CancellationToken ct = default) { Requester = requester; - return Receipt(scopeId, pinKey); + return Task.FromResult(Result(Receipt(scopeId, pinKey))); + } + + private T Result(T value) + { + if (exception != null) + throw exception; + return value; } - private static Task Receipt(string scopeId, string pinKey) => - Task.FromResult(new ContentArtifactPinAcceptedReceipt( + private static ContentArtifactPinAcceptedReceipt Receipt(string scopeId, string pinKey) => + new( scopeId, pinKey, "command-1", "correlation-1", - ContentArtifactCommandStageNames.DispatchAccepted)); + ContentArtifactCommandStageNames.DispatchAccepted); } private sealed class TestHostEnvironment : IHostEnvironment diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs index 79487de046..71a0880b40 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactPinServiceTests.cs @@ -116,7 +116,7 @@ await service.ClearAsync( } [Fact] - public async Task ClearAndGet_ShouldHideAbsentOrOtherOwnersPin() + public async Task ClearAsync_ShouldHideOtherOwnersPin() { var otherOwnerService = new ContentArtifactPinService( new RecordingArtifactQueryPort(current: null), @@ -128,13 +128,68 @@ public async Task ClearAndGet_ShouldHideAbsentOrOtherOwnersPin() new ClearContentArtifactPinRequest(1, "mutation-clear"), Principal("owner-2")); await denied.Should().ThrowAsync(); + } - var absentService = new ContentArtifactPinService( + [Fact] + public async Task GetAsync_ShouldReturnExistingEmptyPointerDocument() + { + var emptyPin = Pin(version: 2) with { PinnedArtifactId = null, PinnedBy = null }; + var service = new ContentArtifactPinService( new RecordingArtifactQueryPort(current: null), - new RecordingPinQueryPort(Pin(version: 2) with { PinnedArtifactId = null, PinnedBy = null }), + new RecordingPinQueryPort(emptyPin), new RecordingPinCommandPort()); - var missing = () => absentService.GetAsync("scope-1", "daily-ops-report"); - await missing.Should().ThrowAsync(); + + var current = await service.GetAsync("scope-1", "daily-ops-report"); + + current.Should().Be(emptyPin); + current.PinVersion.Should().Be(2); + current.LastMutationStatus.Should().Be("succeeded"); + } + + [Fact] + public async Task ClearAsync_ShouldDispatchEmptyPointerReplayForLastMutationRequester() + { + var commandPort = new RecordingPinCommandPort(); + var emptyPin = Pin(version: 2) with + { + PinnedArtifactId = null, + PinnedBy = null, + LastMutationId = "mutation-clear", + }; + var service = new ContentArtifactPinService( + new RecordingArtifactQueryPort(current: null), + new RecordingPinQueryPort(emptyPin), + commandPort); + + await service.ClearAsync( + "scope-1", + "daily-ops-report", + new ClearContentArtifactPinRequest(1, "mutation-clear"), + Principal("owner-1")); + + commandPort.ClearRequest.Should().Be(new ClearContentArtifactPinRequest(1, "mutation-clear")); + } + + [Theory] + [InlineData("different-mutation", "owner-1")] + [InlineData("mutation-2", "owner-2")] + public async Task ClearAsync_ShouldRejectNonReplayAgainstEmptyPointer(string mutationId, string requesterId) + { + var commandPort = new RecordingPinCommandPort(); + var emptyPin = Pin(version: 2) with { PinnedArtifactId = null, PinnedBy = null }; + var service = new ContentArtifactPinService( + new RecordingArtifactQueryPort(current: null), + new RecordingPinQueryPort(emptyPin), + commandPort); + + var act = () => service.ClearAsync( + "scope-1", + "daily-ops-report", + new ClearContentArtifactPinRequest(2, mutationId), + Principal(requesterId)); + + await act.Should().ThrowAsync(); + commandPort.ClearRequest.Should().BeNull(); } private static ContentArtifactCurrentStateResponse Artifact() => @@ -168,7 +223,8 @@ private static ContentArtifactPinCurrentStateResponse Pin(long version) => version, DateTimeOffset.UnixEpoch, $"mutation-{version}", - "succeeded"); + "succeeded", + LastMutationRequestedBy: Principal("owner-1")); private static ContentArtifactPrincipalContract Principal(string id) => new(id, "user"); diff --git a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs index a48babea1c..2f694c70c0 100644 --- a/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs +++ b/test/Aevatar.Studio.Tests/ContentArtifacts/ContentArtifactProjectionTests.cs @@ -196,6 +196,11 @@ public async Task PinCurrentState_ShouldExposeActorPinVersionAndCommittedStateVe UpdatedAtUtc = Timestamp.FromDateTimeOffset(observedAt), LastMutationId = "mutation-3", LastMutationStatus = ContentArtifactPinMutationStatus.Succeeded, + LastMutationRequestedBy = new ContentArtifactPrincipal + { + PrincipalId = "owner-1", + PrincipalKind = "user", + }, }, observedAt); @@ -207,6 +212,43 @@ public async Task PinCurrentState_ShouldExposeActorPinVersionAndCommittedStateVe current.PinVersion.Should().Be(3); current.StateVersion.Should().Be(9); current.PinnedBy!.PrincipalId.Should().Be("owner-1"); + current.LastMutationRequestedBy.Should().Be(new ContentArtifactPrincipalContract("owner-1", "user")); + } + + [Fact] + public async Task ClearedPinCurrentState_ShouldRemainObservable() + { + var observedAt = DateTimeOffset.Parse("2026-08-25T11:00:00Z"); + var document = ContentArtifactPinCurrentStateProjector.ToDocument( + ContentArtifactConventions.BuildPinActorId(ScopeId, "daily-ops-report"), + new StateEvent { Version = 10, EventId = "event-10" }, + new ContentArtifactPinState + { + ScopeId = ScopeId, + PinKey = "daily-ops-report", + PinVersion = 4, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(observedAt), + LastMutationId = "mutation-clear", + LastMutationStatus = ContentArtifactPinMutationStatus.Succeeded, + LastMutationRequestedBy = new ContentArtifactPrincipal + { + PrincipalId = "owner-1", + PrincipalKind = "user", + }, + }, + observedAt); + + var current = await new ProjectionContentArtifactPinQueryPort( + new RecordingPinDocumentReader(document)).GetAsync(ScopeId, "daily-ops-report"); + + current.Should().NotBeNull(); + current!.PinnedArtifactId.Should().BeNull(); + current.PinnedBy.Should().BeNull(); + current.PinVersion.Should().Be(4); + current.StateVersion.Should().Be(10); + current.LastMutationId.Should().Be("mutation-clear"); + current.LastMutationStatus.Should().Be("succeeded"); + current.LastMutationRequestedBy.Should().Be(new ContentArtifactPrincipalContract("owner-1", "user")); } [Fact]