sprints work

This commit is contained in:
master
2026-01-10 11:15:28 +02:00
parent a21d3dbc1f
commit 701eb6b21c
71 changed files with 10854 additions and 136 deletions

View File

@@ -0,0 +1,274 @@
// <copyright file="AiAttestationService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// In-memory implementation of AI attestation service.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-003
/// </summary>
/// <remarks>
/// This implementation stores attestations in memory. For production,
/// use a database-backed implementation with signing integration.
/// </remarks>
public sealed class AiAttestationService : IAiAttestationService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<AiAttestationService> _logger;
private readonly ConcurrentDictionary<string, StoredAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, StoredAttestation> _claimAttestations = new();
public AiAttestationService(
TimeProvider timeProvider,
ILogger<AiAttestationService> logger)
{
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiRunAttestation);
// In production, this would call the signer service
string? dsseEnvelope = null;
if (sign)
{
// Placeholder - real implementation would use StellaOps.Signer
dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.RunId,
AiRunAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_runAttestations[attestation.RunId] = stored;
_logger.LogInformation(
"Created run attestation {RunId} with digest {Digest}, signed={Signed}",
attestation.RunId,
digest,
sign);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.RunId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/run/{attestation.RunId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiClaimAttestation);
string? dsseEnvelope = null;
if (sign)
{
dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.ClaimId,
AiClaimAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_claimAttestations[attestation.ClaimId] = stored;
_logger.LogDebug(
"Created claim attestation {ClaimId} for run {RunId}",
attestation.ClaimId,
attestation.RunId);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.ClaimId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Run attestation {runId} not found"));
}
// Verify digest
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
// In production, verify signature via signer service
bool? signatureValid = stored.DsseEnvelope != null ? true : null;
_logger.LogDebug("Verified run attestation {RunId}", runId);
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_claimAttestations.TryGetValue(claimId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Claim attestation {claimId} not found"));
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiClaimAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult<AiRunAttestation?>(null);
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default)
{
var claims = _claimAttestations.Values
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation))
.Where(c => c != null && c.RunId == runId)
.Cast<AiClaimAttestation>()
.ToList();
return Task.FromResult<IReadOnlyList<AiClaimAttestation>>(claims);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default)
{
var attestations = _runAttestations.Values
.OrderByDescending(s => s.CreatedAt)
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation))
.Where(a => a != null && a.TenantId == tenantId)
.Cast<AiRunAttestation>()
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<AiRunAttestation>>(attestations);
}
private static string CreateMockDsseEnvelope(string predicateType, string payload)
{
// Mock DSSE envelope - real implementation would use StellaOps.Signer
var payloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
return $$"""
{
"payloadType": "{{predicateType}}",
"payload": "{{payloadBase64}}",
"signatures": [{"sig": "mock-signature"}]
}
""";
}
private sealed record StoredAttestation(
string Id,
string PredicateType,
string Json,
string Digest,
string? DsseEnvelope,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,58 @@
// <copyright file="AiAttestationServiceExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.AdvisoryAI.Attestation.Storage;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Extension methods for registering AI attestation services.
/// Sprint: SPRINT_20260109_011_001
/// </summary>
public static class AiAttestationServiceExtensions
{
/// <summary>
/// Adds AI attestation services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAiAttestationServices(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IPromptTemplateRegistry, PromptTemplateRegistry>();
services.TryAddSingleton<IAiAttestationService, AiAttestationService>();
return services;
}
/// <summary>
/// Adds AI attestation services with a custom time provider.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="timeProvider">The time provider to use.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAiAttestationServices(
this IServiceCollection services,
TimeProvider timeProvider)
{
services.AddSingleton(timeProvider);
services.TryAddSingleton<IPromptTemplateRegistry, PromptTemplateRegistry>();
services.TryAddSingleton<IAiAttestationService, AiAttestationService>();
return services;
}
/// <summary>
/// Adds in-memory attestation storage. Useful for testing and development.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddInMemoryAiAttestationStore(this IServiceCollection services)
{
services.TryAddSingleton<IAiAttestationStore, InMemoryAiAttestationStore>();
return services;
}
}

View File

@@ -0,0 +1,173 @@
// <copyright file="IAiAttestationService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Service for creating and verifying AI attestations.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-002
/// </summary>
public interface IAiAttestationService
{
/// <summary>
/// Creates an attestation for an AI run.
/// </summary>
/// <param name="attestation">The attestation to create.</param>
/// <param name="sign">Whether to sign the attestation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created attestation with optional signature.</returns>
Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default);
/// <summary>
/// Creates an attestation for a specific claim.
/// </summary>
/// <param name="attestation">The claim attestation to create.</param>
/// <param name="sign">Whether to sign the attestation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created attestation with optional signature.</returns>
Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default);
/// <summary>
/// Verifies an AI run attestation.
/// </summary>
/// <param name="runId">The run ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default);
/// <summary>
/// Verifies a claim attestation.
/// </summary>
/// <param name="claimId">The claim ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default);
/// <summary>
/// Gets a run attestation by ID.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation if found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default);
/// <summary>
/// Gets claim attestations for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>All claim attestations for the run.</returns>
Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default);
/// <summary>
/// Lists recent run attestations.
/// </summary>
/// <param name="tenantId">Tenant filter.</param>
/// <param name="limit">Maximum results.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Recent attestations.</returns>
Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default);
}
/// <summary>
/// Result of creating an attestation.
/// </summary>
public sealed record AiAttestationResult
{
/// <summary>Attestation ID.</summary>
public required string AttestationId { get; init; }
/// <summary>Content digest.</summary>
public required string Digest { get; init; }
/// <summary>Whether the attestation was signed.</summary>
public bool Signed { get; init; }
/// <summary>DSSE envelope if signed.</summary>
public string? DsseEnvelope { get; init; }
/// <summary>Storage URI.</summary>
public string? StorageUri { get; init; }
/// <summary>Creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Result of verifying an attestation.
/// </summary>
public sealed record AiAttestationVerificationResult
{
/// <summary>Whether verification succeeded.</summary>
public required bool Valid { get; init; }
/// <summary>Verification timestamp.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>Signing key ID if signed.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Key expiration if applicable.</summary>
public DateTimeOffset? KeyExpiresAt { get; init; }
/// <summary>Digest verification result.</summary>
public bool DigestValid { get; init; }
/// <summary>Signature verification result.</summary>
public bool? SignatureValid { get; init; }
/// <summary>Verification failure reason if invalid.</summary>
public string? FailureReason { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static AiAttestationVerificationResult Success(
DateTimeOffset verifiedAt,
string? signingKeyId = null,
DateTimeOffset? keyExpiresAt = null) => new()
{
Valid = true,
VerifiedAt = verifiedAt,
SigningKeyId = signingKeyId,
KeyExpiresAt = keyExpiresAt,
DigestValid = true,
SignatureValid = signingKeyId != null ? true : null
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static AiAttestationVerificationResult Failure(
DateTimeOffset verifiedAt,
string reason,
bool digestValid = false,
bool? signatureValid = null) => new()
{
Valid = false,
VerifiedAt = verifiedAt,
DigestValid = digestValid,
SignatureValid = signatureValid,
FailureReason = reason
};
}

View File

@@ -0,0 +1,28 @@
// <copyright file="AiAttestationJsonContext.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// JSON source generation context for AI attestation models.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(AiRunAttestation))]
[JsonSerializable(typeof(AiClaimAttestation))]
[JsonSerializable(typeof(AiTurnSummary))]
[JsonSerializable(typeof(AiModelInfo))]
[JsonSerializable(typeof(AiModelParameters))]
[JsonSerializable(typeof(PromptTemplateInfo))]
[JsonSerializable(typeof(ClaimEvidence))]
[JsonSerializable(typeof(AiRunContext))]
[JsonSerializable(typeof(ToolCallSummary))]
public partial class AiAttestationJsonContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,139 @@
// <copyright file="AiClaimAttestation.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Attestation for a specific AI claim, providing fine-grained provenance.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
/// <remarks>
/// While AiRunAttestation captures the full run, AiClaimAttestation allows
/// individual claims to be attested separately. This enables:
/// - Granular trust verification
/// - Claim-specific evidence linkage
/// - Selective claim citation in reports
/// </remarks>
public sealed record AiClaimAttestation
{
/// <summary>Attestation type URI.</summary>
public const string PredicateType = "https://stellaops.org/attestation/ai-claim/v1";
/// <summary>Unique claim identifier.</summary>
[JsonPropertyName("claimId")]
public required string ClaimId { get; init; }
/// <summary>Parent run ID.</summary>
[JsonPropertyName("runId")]
public required string RunId { get; init; }
/// <summary>Turn ID where claim was made.</summary>
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
/// <summary>Tenant identifier.</summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>The claim text.</summary>
[JsonPropertyName("claimText")]
public required string ClaimText { get; init; }
/// <summary>SHA-256 hash of the claim text.</summary>
[JsonPropertyName("claimDigest")]
public required string ClaimDigest { get; init; }
/// <summary>Claim category.</summary>
[JsonPropertyName("category")]
public ClaimCategory Category { get; init; } = ClaimCategory.Factual;
/// <summary>Evidence URIs grounding this claim.</summary>
[JsonPropertyName("groundedBy")]
public ImmutableArray<string> GroundedBy { get; init; } = [];
/// <summary>Grounding confidence score.</summary>
[JsonPropertyName("groundingScore")]
public double GroundingScore { get; init; }
/// <summary>Whether the claim was verified.</summary>
[JsonPropertyName("verified")]
public bool Verified { get; init; }
/// <summary>Verification method used.</summary>
[JsonPropertyName("verificationMethod")]
public string? VerificationMethod { get; init; }
/// <summary>Claim timestamp.</summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Context information.</summary>
[JsonPropertyName("context")]
public AiRunContext? Context { get; init; }
/// <summary>Content digest for this attestation.</summary>
[JsonPropertyName("contentDigest")]
public required string ContentDigest { get; init; }
/// <summary>Claim type category.</summary>
[JsonPropertyName("claimType")]
public string? ClaimType { get; init; }
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiClaimAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Creates a claim attestation from a claim evidence.
/// </summary>
public static AiClaimAttestation FromClaimEvidence(
ClaimEvidence evidence,
string runId,
string turnId,
string tenantId,
DateTimeOffset timestamp,
AiRunContext? context = null)
{
var claimDigest = ComputeClaimDigest(evidence.Text);
var claimId = $"claim-{Guid.NewGuid():N}";
var attestation = new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = turnId,
TenantId = tenantId,
ClaimText = evidence.Text,
ClaimDigest = claimDigest,
Category = evidence.Category,
GroundedBy = evidence.GroundedBy,
GroundingScore = evidence.GroundingScore,
Verified = evidence.Verified,
Timestamp = timestamp,
Context = context,
ContentDigest = "" // Placeholder, computed below
};
// Now compute the actual content digest
return attestation with { ContentDigest = attestation.ComputeDigest() };
}
private static string ComputeClaimDigest(string claimText)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(claimText));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="AiModelInfo.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Information about the AI model used in a run.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record AiModelInfo
{
/// <summary>Model provider (e.g., "anthropic", "openai", "local").</summary>
[JsonPropertyName("provider")]
public required string Provider { get; init; }
/// <summary>Model identifier (e.g., "claude-3-sonnet", "gpt-4o").</summary>
[JsonPropertyName("modelId")]
public required string ModelId { get; init; }
/// <summary>Model version or digest for reproducibility.</summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>Model parameters used (temperature, etc.).</summary>
[JsonPropertyName("parameters")]
public AiModelParameters? Parameters { get; init; }
}
/// <summary>
/// Model inference parameters.
/// </summary>
public sealed record AiModelParameters
{
/// <summary>Sampling temperature.</summary>
[JsonPropertyName("temperature")]
public double? Temperature { get; init; }
/// <summary>Top-p nucleus sampling.</summary>
[JsonPropertyName("topP")]
public double? TopP { get; init; }
/// <summary>Maximum tokens to generate.</summary>
[JsonPropertyName("maxTokens")]
public int? MaxTokens { get; init; }
/// <summary>Random seed for reproducibility.</summary>
[JsonPropertyName("seed")]
public long? Seed { get; init; }
}

View File

@@ -0,0 +1,118 @@
// <copyright file="AiRunAttestation.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Attestation for an AI run, containing signed proof of AI-generated content.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
/// <remarks>
/// This attestation captures everything needed to verify and reproduce an AI run:
/// - Who ran it (tenant, user)
/// - What model was used
/// - What prompt template was used
/// - What context was provided
/// - What was said (content digests)
/// - What claims were made and their grounding evidence
/// </remarks>
public sealed record AiRunAttestation
{
/// <summary>Attestation type URI.</summary>
public const string PredicateType = "https://stellaops.org/attestation/ai-run/v1";
/// <summary>Unique run identifier.</summary>
[JsonPropertyName("runId")]
public required string RunId { get; init; }
/// <summary>Tenant identifier.</summary>
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
/// <summary>User identifier.</summary>
[JsonPropertyName("userId")]
public required string UserId { get; init; }
/// <summary>Conversation ID (for multi-run conversations).</summary>
[JsonPropertyName("conversationId")]
public string? ConversationId { get; init; }
/// <summary>Run start timestamp.</summary>
[JsonPropertyName("startedAt")]
public required DateTimeOffset StartedAt { get; init; }
/// <summary>Run completion timestamp.</summary>
[JsonPropertyName("completedAt")]
public required DateTimeOffset CompletedAt { get; init; }
/// <summary>Model information.</summary>
[JsonPropertyName("model")]
public required AiModelInfo Model { get; init; }
/// <summary>Prompt template information.</summary>
[JsonPropertyName("promptTemplate")]
public PromptTemplateInfo? PromptTemplate { get; init; }
/// <summary>Context information.</summary>
[JsonPropertyName("context")]
public AiRunContext? Context { get; init; }
/// <summary>Turn summaries.</summary>
[JsonPropertyName("turns")]
public ImmutableArray<AiTurnSummary> Turns { get; init; } = [];
/// <summary>Overall grounding score (0.0 to 1.0).</summary>
[JsonPropertyName("overallGroundingScore")]
public double OverallGroundingScore { get; init; }
/// <summary>Total tokens used.</summary>
[JsonPropertyName("totalTokens")]
public int TotalTokens { get; init; }
/// <summary>Run status.</summary>
[JsonPropertyName("status")]
public AiRunStatus Status { get; init; } = AiRunStatus.Completed;
/// <summary>Error message if failed.</summary>
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiRunAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// AI run status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AiRunStatus>))]
public enum AiRunStatus
{
/// <summary>Run completed successfully.</summary>
Completed,
/// <summary>Run failed.</summary>
Failed,
/// <summary>Run was cancelled.</summary>
Cancelled,
/// <summary>Run timed out.</summary>
TimedOut,
/// <summary>Run was blocked by guardrails.</summary>
Blocked
}

View File

@@ -0,0 +1,48 @@
// <copyright file="AiRunContext.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Context information for an AI run.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record AiRunContext
{
/// <summary>Finding ID if analyzing a finding.</summary>
[JsonPropertyName("findingId")]
public string? FindingId { get; init; }
/// <summary>CVE ID if relevant.</summary>
[JsonPropertyName("cveId")]
public string? CveId { get; init; }
/// <summary>Component PURL if relevant.</summary>
[JsonPropertyName("component")]
public string? Component { get; init; }
/// <summary>Image digest if analyzing an image.</summary>
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
/// <summary>SBOM ID if referenced.</summary>
[JsonPropertyName("sbomId")]
public string? SbomId { get; init; }
/// <summary>Policy ID if relevant.</summary>
[JsonPropertyName("policyId")]
public string? PolicyId { get; init; }
/// <summary>Additional context key-value pairs.</summary>
[JsonPropertyName("metadata")]
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>Evidence URIs used as grounding context.</summary>
[JsonPropertyName("evidenceUris")]
public ImmutableArray<string> EvidenceUris { get; init; } = [];
}

View File

@@ -0,0 +1,92 @@
// <copyright file="AiTurnSummary.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Summary of a single turn in an AI conversation.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record AiTurnSummary
{
/// <summary>Unique turn identifier.</summary>
[JsonPropertyName("turnId")]
public required string TurnId { get; init; }
/// <summary>Turn role (user, assistant, system).</summary>
[JsonPropertyName("role")]
public required TurnRole Role { get; init; }
/// <summary>SHA-256 hash of the turn content.</summary>
[JsonPropertyName("contentDigest")]
public required string ContentDigest { get; init; }
/// <summary>Turn timestamp.</summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Token count for this turn.</summary>
[JsonPropertyName("tokenCount")]
public int TokenCount { get; init; }
/// <summary>Claims made in this turn (for assistant turns).</summary>
[JsonPropertyName("claims")]
public ImmutableArray<ClaimEvidence> Claims { get; init; } = [];
/// <summary>Overall grounding score for assistant turns.</summary>
[JsonPropertyName("groundingScore")]
public double? GroundingScore { get; init; }
/// <summary>Tool calls made in this turn.</summary>
[JsonPropertyName("toolCalls")]
public ImmutableArray<ToolCallSummary> ToolCalls { get; init; } = [];
}
/// <summary>
/// Turn role in conversation.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TurnRole>))]
public enum TurnRole
{
/// <summary>User message.</summary>
User,
/// <summary>Assistant response.</summary>
Assistant,
/// <summary>System prompt.</summary>
System,
/// <summary>Tool response.</summary>
Tool
}
/// <summary>
/// Summary of a tool call.
/// </summary>
public sealed record ToolCallSummary
{
/// <summary>Tool name.</summary>
[JsonPropertyName("toolName")]
public required string ToolName { get; init; }
/// <summary>Hash of input arguments.</summary>
[JsonPropertyName("inputDigest")]
public required string InputDigest { get; init; }
/// <summary>Hash of output.</summary>
[JsonPropertyName("outputDigest")]
public required string OutputDigest { get; init; }
/// <summary>Tool execution duration.</summary>
[JsonPropertyName("durationMs")]
public long DurationMs { get; init; }
/// <summary>Whether the tool call succeeded.</summary>
[JsonPropertyName("success")]
public bool Success { get; init; } = true;
}

View File

@@ -0,0 +1,68 @@
// <copyright file="ClaimEvidence.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Evidence grounding an AI claim.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record ClaimEvidence
{
/// <summary>The claim text.</summary>
[JsonPropertyName("text")]
public required string Text { get; init; }
/// <summary>Character position in the response.</summary>
[JsonPropertyName("position")]
public required int Position { get; init; }
/// <summary>Length of the claim text.</summary>
[JsonPropertyName("length")]
public required int Length { get; init; }
/// <summary>Evidence URIs grounding this claim (stella:// URIs).</summary>
[JsonPropertyName("groundedBy")]
public ImmutableArray<string> GroundedBy { get; init; } = [];
/// <summary>Grounding confidence score (0.0 to 1.0).</summary>
[JsonPropertyName("groundingScore")]
public double GroundingScore { get; init; }
/// <summary>Whether this claim was verified against evidence.</summary>
[JsonPropertyName("verified")]
public bool Verified { get; init; }
/// <summary>Claim category (factual, recommendation, caveat, etc.).</summary>
[JsonPropertyName("category")]
public ClaimCategory Category { get; init; } = ClaimCategory.Factual;
}
/// <summary>
/// Categories of AI claims.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ClaimCategory>))]
public enum ClaimCategory
{
/// <summary>Factual statement about the subject.</summary>
Factual,
/// <summary>Recommendation or suggested action.</summary>
Recommendation,
/// <summary>Caveat or limitation.</summary>
Caveat,
/// <summary>Explanation or reasoning.</summary>
Explanation,
/// <summary>Reference to documentation or resources.</summary>
Reference,
/// <summary>Unknown or unclassified claim.</summary>
Unknown
}

View File

@@ -0,0 +1,30 @@
// <copyright file="PromptTemplateInfo.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// Information about the prompt template used in a run.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-001
/// </summary>
public sealed record PromptTemplateInfo
{
/// <summary>Template name.</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Template version.</summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>Content hash for verification.</summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>Template parameters used.</summary>
[JsonPropertyName("parameters")]
public IReadOnlyDictionary<string, string>? Parameters { get; init; }
}

View File

@@ -0,0 +1,150 @@
// <copyright file="PromptTemplateRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Interface for prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public interface IPromptTemplateRegistry
{
/// <summary>
/// Registers a prompt template with version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <param name="template">Template content.</param>
void Register(string name, string version, string template);
/// <summary>
/// Gets template info including hash.
/// </summary>
/// <param name="name">Template name.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name);
/// <summary>
/// Gets template info for a specific version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name, string version);
/// <summary>
/// Verifies a template hash matches registered version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="expectedHash">Expected hash.</param>
/// <returns>True if hash matches.</returns>
bool VerifyHash(string name, string expectedHash);
/// <summary>
/// Gets all registered templates.
/// </summary>
/// <returns>All template info records.</returns>
IReadOnlyList<PromptTemplateInfo> GetAllTemplates();
}
/// <summary>
/// In-memory implementation of prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public sealed class PromptTemplateRegistry : IPromptTemplateRegistry
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<PromptTemplateRegistry> _logger;
private readonly ConcurrentDictionary<string, PromptTemplateInfo> _latestVersions = new();
private readonly ConcurrentDictionary<(string Name, string Version), PromptTemplateInfo> _allVersions = new();
public PromptTemplateRegistry(
TimeProvider timeProvider,
ILogger<PromptTemplateRegistry> logger)
{
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc/>
public void Register(string name, string version, string template)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ArgumentException.ThrowIfNullOrWhiteSpace(template);
var digest = ComputeDigest(template);
var now = _timeProvider.GetUtcNow();
var info = new PromptTemplateInfo
{
Name = name,
Version = version,
Digest = digest
};
_allVersions[(name, version)] = info;
_latestVersions[name] = info;
_logger.LogInformation(
"Registered prompt template {Name} v{Version} with digest {Digest}",
name,
version,
digest);
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name)
{
return _latestVersions.TryGetValue(name, out var info) ? info : null;
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name, string version)
{
return _allVersions.TryGetValue((name, version), out var info) ? info : null;
}
/// <inheritdoc/>
public bool VerifyHash(string name, string expectedHash)
{
if (!_latestVersions.TryGetValue(name, out var info))
{
_logger.LogWarning("Template {Name} not found for hash verification", name);
return false;
}
var matches = string.Equals(info.Digest, expectedHash, StringComparison.OrdinalIgnoreCase);
if (!matches)
{
_logger.LogWarning(
"Hash mismatch for template {Name}: expected {Expected}, got {Actual}",
name,
expectedHash,
info.Digest);
}
return matches;
}
/// <inheritdoc/>
public IReadOnlyList<PromptTemplateInfo> GetAllTemplates()
{
return [.. _latestVersions.Values];
}
private static string ComputeDigest(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.AdvisoryAI.Attestation</RootNamespace>
<Description>AI attestation models and services for StellaOps Advisory AI</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Attestation.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,103 @@
// <copyright file="IAiAttestationStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
/// <summary>
/// Interface for storing and retrieving AI attestations.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-006
/// </summary>
public interface IAiAttestationStore
{
/// <summary>
/// Store a run attestation.
/// </summary>
/// <param name="attestation">The attestation to store.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct);
/// <summary>
/// Store a signed attestation envelope.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="envelope">The signed DSSE envelope.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct);
/// <summary>
/// Store a claim attestation.
/// </summary>
/// <param name="attestation">The attestation to store.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct);
/// <summary>
/// Get a run attestation by run ID.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation or null if not found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct);
/// <summary>
/// Get all claim attestations for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get claim attestations for a specific turn.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="turnId">The turn ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations for the turn.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct);
/// <summary>
/// Get the signed envelope for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The signed envelope or null if not found.</returns>
Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct);
/// <summary>
/// Check if a run attestation exists.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the attestation exists.</returns>
Task<bool> ExistsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get attestations by tenant within a time range.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="from">Start time.</param>
/// <param name="to">End time.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of run attestations.</returns>
Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct);
/// <summary>
/// Get attestation by content digest.
/// </summary>
/// <param name="contentDigest">The content digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The claim attestation or null if not found.</returns>
Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct);
}

View File

@@ -0,0 +1,166 @@
// <copyright file="InMemoryAiAttestationStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
/// <summary>
/// In-memory implementation of AI attestation store.
/// Useful for testing and development.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-006
/// </summary>
public sealed class InMemoryAiAttestationStore : IAiAttestationStore
{
private readonly ConcurrentDictionary<string, AiRunAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, object> _signedEnvelopes = new();
private readonly ConcurrentDictionary<string, List<AiClaimAttestation>> _claimAttestations = new();
private readonly ConcurrentDictionary<string, AiClaimAttestation> _digestIndex = new();
private readonly ILogger<InMemoryAiAttestationStore> _logger;
public InMemoryAiAttestationStore(ILogger<InMemoryAiAttestationStore> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct)
{
_runAttestations[attestation.RunId] = attestation;
_logger.LogDebug("Stored run attestation for RunId {RunId}", attestation.RunId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct)
{
_signedEnvelopes[runId] = envelope;
_logger.LogDebug("Stored signed envelope for RunId {RunId}", runId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct)
{
var claims = _claimAttestations.GetOrAdd(attestation.RunId, _ => []);
lock (claims)
{
claims.Add(attestation);
}
_digestIndex[attestation.ContentDigest] = attestation;
_logger.LogDebug(
"Stored claim attestation for RunId {RunId}, TurnId {TurnId}",
attestation.RunId,
attestation.TurnId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
{
_runAttestations.TryGetValue(runId, out var attestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
return Task.FromResult(claims.ToImmutableArray());
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
var filtered = claims
.Where(c => c.TurnId == turnId)
.ToImmutableArray();
return Task.FromResult(filtered);
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct)
{
_signedEnvelopes.TryGetValue(runId, out var envelope);
return Task.FromResult(envelope);
}
/// <inheritdoc/>
public Task<bool> ExistsAsync(string runId, CancellationToken ct)
{
return Task.FromResult(_runAttestations.ContainsKey(runId));
}
/// <inheritdoc/>
public Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct)
{
var results = _runAttestations.Values
.Where(a => a.TenantId == tenantId &&
a.StartedAt >= from &&
a.StartedAt <= to)
.OrderBy(a => a.StartedAt)
.ToImmutableArray();
return Task.FromResult(results);
}
/// <inheritdoc/>
public Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct)
{
_digestIndex.TryGetValue(contentDigest, out var attestation);
return Task.FromResult(attestation);
}
/// <summary>
/// Clear all stored attestations. Useful for testing.
/// </summary>
public void Clear()
{
_runAttestations.Clear();
_signedEnvelopes.Clear();
_claimAttestations.Clear();
_digestIndex.Clear();
}
/// <summary>
/// Get count of run attestations. Useful for testing.
/// </summary>
public int RunAttestationCount => _runAttestations.Count;
/// <summary>
/// Get count of all claim attestations. Useful for testing.
/// </summary>
public int ClaimAttestationCount => _claimAttestations.Values.Sum(c =>
{
lock (c) { return c.Count; }
});
}