diff --git a/AgentExperience.NET.sln b/AgentExperience.NET.sln index c4f60b5..2829344 100644 --- a/AgentExperience.NET.sln +++ b/AgentExperience.NET.sln @@ -21,6 +21,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.MicrosoftAg EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.MicrosoftAgentFramework.Tests", "tests\AgentExperience.MicrosoftAgentFramework.Tests\AgentExperience.MicrosoftAgentFramework.Tests.csproj", "{9B771769-47C4-4EA1-8808-3E4728899A04}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres", "src\AgentExperience.Storage.Postgres\AgentExperience.Storage.Postgres.csproj", "{DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Tests", "tests\AgentExperience.Storage.Postgres.Tests\AgentExperience.Storage.Postgres.Tests.csproj", "{86C76642-C4EB-49A8-9E4C-A885644EE49F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +119,30 @@ Global {9B771769-47C4-4EA1-8808-3E4728899A04}.Release|x64.Build.0 = Release|Any CPU {9B771769-47C4-4EA1-8808-3E4728899A04}.Release|x86.ActiveCfg = Release|Any CPU {9B771769-47C4-4EA1-8808-3E4728899A04}.Release|x86.Build.0 = Release|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Debug|x64.ActiveCfg = Debug|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Debug|x64.Build.0 = Debug|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Debug|x86.ActiveCfg = Debug|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Debug|x86.Build.0 = Debug|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Release|Any CPU.Build.0 = Release|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Release|x64.ActiveCfg = Release|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Release|x64.Build.0 = Release|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Release|x86.ActiveCfg = Release|Any CPU + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E}.Release|x86.Build.0 = Release|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Debug|x64.ActiveCfg = Debug|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Debug|x64.Build.0 = Debug|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Debug|x86.ActiveCfg = Debug|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Debug|x86.Build.0 = Debug|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|Any CPU.Build.0 = Release|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x64.ActiveCfg = Release|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x64.Build.0 = Release|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x86.ActiveCfg = Release|Any CPU + {86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -127,5 +155,7 @@ Global {6E9367BD-1AE0-4D8E-84C2-B73809F57779} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {9CDECFCD-7D5E-4794-9909-5F836FCD35BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {9B771769-47C4-4EA1-8808-3E4728899A04} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {86C76642-C4EB-49A8-9E4C-A885644EE49F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index b425578..57e7f9d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ AgentExperience.NET captures what an AI agent actually tried, verifies whether it worked, and turns the result into an auditable lesson that future runs can reuse safely. It sits between [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) (MAF) execution and durable storage, without replacing either. -> **Status: early development.** Epic 1 (capture and explain agent experience) is implemented and tested. Persistence, retrieval, and governance are planned (see [Roadmap](#roadmap)). Nothing is published to NuGet yet, and APIs may change. +> **Status: early development.** Epic 1 (capture and explain agent experience) is implemented and tested. Epic 2 has started: Experience Records can be stored in PostgreSQL. Retrieval, injection, and governance are planned (see [Roadmap](#roadmap)). Nothing is published to NuGet yet, and APIs may change. ## Why @@ -30,6 +30,7 @@ AgentExperience.NET records observable evidence (tool calls, results, errors, ve | Deterministic task verification: exit codes, tests, workflow and human checks; host-closed rounds; no LLM | `AgentExperience.Core` | | Auditable, template-based reflections traceable to evidence IDs | `AgentExperience.Core` | | MAF adapter: captures ordinary, streaming, failed, and cancelled runs plus tool calls, without altering results | `AgentExperience.MicrosoftAgentFramework` | +| PostgreSQL Experience Record store: create, get, and scoped query; host authorization checked before database access; exact scope matching in SQL | `AgentExperience.Storage.Postgres` | ## Quick look @@ -49,7 +50,7 @@ await agent.RunAsync("Triage ticket #4812", session); // The run, its tool calls, and its sanitized outcome are now available from captureService. ``` -See the [adapter README](src/AgentExperience.MicrosoftAgentFramework/README.md) for options, supported agent types, and caveats. +See the [adapter README](src/AgentExperience.MicrosoftAgentFramework/README.md) for options, supported agent types, and caveats. See the [PostgreSQL store README](src/AgentExperience.Storage.Postgres/README.md) for the trust boundary, schema script, and data semantics. ## Design principles @@ -66,10 +67,12 @@ src/ AgentExperience.Abstractions/ domain contracts and ports (BCL only) AgentExperience.Core/ sanitization, capture, verification, reflection AgentExperience.MicrosoftAgentFramework/ MAF adapter (pinned Microsoft.Agents.AI 1.20.0) + AgentExperience.Storage.Postgres/ PostgreSQL Experience Record store (pinned Npgsql 10.0.3) tests/ AgentExperience.Abstractions.Tests/ contract and dependency-boundary tests AgentExperience.Core.Tests/ sanitizer, capture, verification, reflection tests AgentExperience.MicrosoftAgentFramework.Tests/ real ChatClientAgent runs against a scripted fake model + AgentExperience.Storage.Postgres.Tests/ store tests, mostly against a PostgreSQL container AgentExperience.CompatibilityProof/ executable proofs for MAF hooks, context providers, pgvector, redaction docs/ original production architecture research _bmad-output/ product brief, PRD, architecture, epics, and specs @@ -85,16 +88,16 @@ dotnet build dotnet test ``` -Unit and adapter tests run in memory, with no network, database, or model credentials. `AgentExperience.CompatibilityProof` starts a PostgreSQL/pgvector container through Testcontainers, so it needs Docker. To skip it: +Unit and MAF adapter tests run in memory, with no network, database, or model credentials. `AgentExperience.CompatibilityProof` and the `PostgresExperienceRecordStoreTests` in `AgentExperience.Storage.Postgres.Tests` start a PostgreSQL/pgvector container through Testcontainers, so they need Docker. If Testcontainers' Ryuk container fails to start under your local Docker setup, set `TESTCONTAINERS_RYUK_DISABLED=true`. To skip the container-backed tests: ```bash -dotnet test --filter "FullyQualifiedName!~CompatibilityProof" +dotnet test --filter "FullyQualifiedName!~CompatibilityProof&FullyQualifiedName!~PostgresExperienceRecordStoreTests" ``` ## Roadmap 1. **Capture and explain agent experience** ✅ contracts, sanitization, capture, verification, reflection, MAF adapter -2. **Reuse relevant experience:** PostgreSQL persistence, hybrid text and vector retrieval, historical-reference injection into MAF +2. **Reuse relevant experience:** PostgreSQL persistence (Experience Record store in place), hybrid text and vector retrieval, historical-reference injection into MAF 3. **Govern experience safely:** sharing grants, audited lifecycle transitions, evidence-based confidence updates 4. **Operate and measure the learning loop:** OpenTelemetry instrumentation, an end-to-end demo, measured reuse against a baseline, data deletion and expiry diff --git a/src/AgentExperience.Abstractions/ExperienceRecord.cs b/src/AgentExperience.Abstractions/ExperienceRecord.cs new file mode 100644 index 0000000..f4d0a40 --- /dev/null +++ b/src/AgentExperience.Abstractions/ExperienceRecord.cs @@ -0,0 +1,45 @@ +namespace AgentExperience.Abstractions; + +/// +/// A durable, scoped Experience Record: the canonical, immutable snapshot of what was learned from +/// one captured -- its attempts, verified outcome, reflection, environment, +/// provenance, lifecycle status, and reuse-confidence inputs. Schema versioning of the persisted +/// form is owned by the storage adapter; this contract carries no version field. +/// +/// Unique identifier for this record. Must not be . +/// The this record was derived from. +/// The tenancy/ownership scope this record belongs to. Required fields must be non-blank; optional fields are either or non-blank. +/// Identifies which task this experience is about. Must be non-blank. +/// Optional, sanitized, human-readable summary of the task. +/// The observable attempts from the source run, in the order they occurred. +/// The task verification outcome the record was finalized against. +/// The fraction of required checks that conclusively passed, in [0, 1]. Not reuse confidence. +/// The auditable reflection derived from the run; when the record is quarantined without an eligible lesson. +/// The runtime environment the source run executed in. +/// Where this record's source capture originated. +/// The record's current lifecycle status. +/// The record's current reuse confidence, in [0, 1]. +/// Non-negative count of validations supporting reuse, kept so confidence can be recomputed. +/// Non-negative count of contradictions observed against reuse, kept so confidence can be recomputed. +/// Non-negative revision number used for optimistic concurrency by lifecycle commits. +/// When the record was created. Persisted and returned in UTC. +/// When the record was last changed. Persisted and returned in UTC. +public sealed record ExperienceRecord( + Guid ExperienceId, + Guid SourceRunId, + Scope Scope, + string TaskId, + string? TaskSummary, + IReadOnlyList Attempts, + Outcome Outcome, + double CompletionScore, + Reflection? Reflection, + EnvironmentFingerprint Environment, + Provenance Provenance, + ExperienceStatus Status, + double ReuseConfidence, + int SupportingValidations, + int Contradictions, + long Revision, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); diff --git a/src/AgentExperience.Abstractions/ExperienceRecordStore.cs b/src/AgentExperience.Abstractions/ExperienceRecordStore.cs new file mode 100644 index 0000000..91f48b7 --- /dev/null +++ b/src/AgentExperience.Abstractions/ExperienceRecordStore.cs @@ -0,0 +1,167 @@ +namespace AgentExperience.Abstractions; + +/// +/// Port for durable, scoped persistence of canonical s. Every +/// operation takes a host-established ; a request scope outside +/// it is before any storage access, and scope matching +/// is exact (ordinal, case-sensitive, matches only ). +/// Expected conditions return typed results; infrastructure failures throw +/// ; caller cancellation surfaces as an unwrapped +/// . +/// +public interface IExperienceRecordStore +{ + /// + /// Persists a new record (create-only). An existing + /// in any scope yields and leaves the stored record + /// unchanged; the result never reveals whether the existing record is in the caller's scope. + /// A retried create whose earlier acknowledgement was lost (for example cancelled or timed out + /// after the commit) also returns ; follow a + /// with in your own scope to + /// check whether the stored record is yours. + /// + /// What the host has established the caller may do. + /// The record to persist; its must lie within . + /// Cancels the operation. + /// , , , or . + Task CreateAsync( + AuthorizationContext authorization, + ExperienceRecord record, + CancellationToken cancellationToken); + + /// + /// Reads one record by ID within exactly . A record that exists in a + /// different scope is indistinguishable from a missing one (). + /// + /// What the host has established the caller may do. + /// The exact request scope to read within. + /// The record to read. Must not be . + /// Cancels the operation. + /// , , , or . + Task GetAsync( + AuthorizationContext authorization, + Scope scope, + Guid experienceId, + CancellationToken cancellationToken); + + /// + /// Lists records within exactly , optionally filtered by + /// status, bounded by , newest first + /// ( descending, then in a stable, store-defined order). + /// + /// What the host has established the caller may do. + /// The scoped query. + /// Cancels the operation. + /// (possibly with no records), , or . + Task QueryAsync( + AuthorizationContext authorization, + ExperienceRecordQuery query, + CancellationToken cancellationToken); +} + +/// +/// A scoped query over s. +/// +/// The exact scope to query within. Never treated as authority. +/// Optional status filter; returns every status. When supplied it must be non-empty and contain only defined values. +/// Maximum number of records to return, from to . Defaults to . +public sealed record ExperienceRecordQuery( + Scope Scope, + IReadOnlyList? Statuses = null, + int Limit = ExperienceRecordQuery.DefaultLimit) +{ + /// The smallest permitted . + public const int MinLimit = 1; + + /// The largest permitted . + public const int MaxLimit = 500; + + /// The used when none is specified. + public const int DefaultLimit = 50; +} + +/// +/// The disposition an operation reached. +/// +public enum ExperienceStoreOutcome +{ + /// The record was persisted. + Created, + + /// The requested record(s) were read. A query with no matches is still . + Found, + + /// No record with that ID exists within the requested scope (including when it exists in another scope). + NotFound, + + /// The request scope lies outside the host-established authorization. No storage was accessed. + Denied, + + /// The request was malformed. See the result's validation errors. No storage was accessed. + Invalid, + + /// A record with the same ID already exists in some scope. The stored record is unchanged and not revealed. + Conflict, +} + +/// +/// One validation failure on a store request. +/// +/// The field path that failed validation, e.g. "Scope.TenantId" or "Attempts[0].ToolCalls[1].ToolName". +/// A content-free, human-readable explanation. Never echoes the offending value. +public sealed record StoreValidationError(string Path, string Message); + +/// +/// The result of . +/// +/// What happened. +/// Every validation error when is ; otherwise empty. +public sealed record ExperienceRecordCreateResult( + ExperienceStoreOutcome Outcome, + IReadOnlyList Errors); + +/// +/// The result of . +/// +/// What happened. +/// The record when is ; otherwise . +/// Every validation error when is ; otherwise empty. +public sealed record ExperienceRecordGetResult( + ExperienceStoreOutcome Outcome, + ExperienceRecord? Record, + IReadOnlyList Errors); + +/// +/// The result of . +/// +/// What happened. +/// The matching records when is ; otherwise empty. +/// Every validation error when is ; otherwise empty. +public sealed record ExperienceRecordQueryResult( + ExperienceStoreOutcome Outcome, + IReadOnlyList Records, + IReadOnlyList Errors); + +/// +/// Thrown by an implementation when storage infrastructure +/// fails (database unavailable, driver error, timeout) or a stored record cannot be read (for +/// example an unsupported payload version). The original failure, when any, is the +/// . Never carries record payload content. +/// +public class ExperienceStoreException : Exception +{ + /// Creates an exception with a content-free message. + /// A content-free description of the failure. + public ExperienceStoreException(string message) + : base(message) + { + } + + /// Creates an exception wrapping the original infrastructure failure. + /// A content-free description of the failure. + /// The original failure. + public ExperienceStoreException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AgentExperience.Abstractions/Scope.cs b/src/AgentExperience.Abstractions/Scope.cs index 9ed52cf..c9e1f08 100644 --- a/src/AgentExperience.Abstractions/Scope.cs +++ b/src/AgentExperience.Abstractions/Scope.cs @@ -28,15 +28,60 @@ public sealed record Scope( /// before a request is evaluated; only selects within it. Request-supplied /// scope can narrow an but can never widen it. /// +/// +/// The optional bounds (, , , +/// , ) restrict which request scopes this context permits. +/// A non-null bound must equal the corresponding field exactly (ordinal, +/// case-sensitive); a bound leaves that field unrestricted. +/// must always match. See . +/// /// The tenant the host has authorized this caller to act within. /// An opaque, host-assigned identifier for the authorized caller. Not tied to any specific identity-provider shape (no claims, tokens, or provider-specific types). /// The roles or capabilities the host has granted this caller, as opaque strings. /// When the host established this authorization context. +/// Optional bound. When non-null, only request scopes with exactly this are permitted. +/// Optional bound. When non-null, only request scopes with exactly this are permitted. +/// Optional bound. When non-null, only request scopes with exactly this are permitted. +/// Optional bound. When non-null, only request scopes with exactly this are permitted. +/// Optional bound. When non-null, only request scopes with exactly this are permitted. public sealed record AuthorizationContext( string TenantId, string PrincipalId, IReadOnlyList Roles, - DateTimeOffset IssuedAt); + DateTimeOffset IssuedAt, + string? ApplicationId = null, + string? ProjectId = null, + string? TeamId = null, + string? AgentId = null, + string? UserId = null) +{ + /// + /// Determines whether this host-established authorization permits a request in + /// . must equal , and + /// every non-null bound must equal the corresponding scope field; all comparisons are ordinal and + /// case-sensitive. A bound leaves its field unrestricted, and a + /// , empty, or whitespace permits nothing. This never widens + /// authority: the request scope is only ever checked against the context, never trusted on its own. + /// + /// The request scope to check. + /// when the scope lies within this authorization; otherwise . + /// is . + public bool Permits(Scope scope) + { + ArgumentNullException.ThrowIfNull(scope); + + return !string.IsNullOrWhiteSpace(TenantId) + && string.Equals(TenantId, scope.TenantId, StringComparison.Ordinal) + && BoundMatches(ApplicationId, scope.ApplicationId) + && BoundMatches(ProjectId, scope.ProjectId) + && BoundMatches(TeamId, scope.TeamId) + && BoundMatches(AgentId, scope.AgentId) + && BoundMatches(UserId, scope.UserId); + } + + private static bool BoundMatches(string? bound, string? value) => + bound is null || string.Equals(bound, value, StringComparison.Ordinal); +} /// /// A fingerprint of the runtime environment an executed in, captured diff --git a/src/AgentExperience.Storage.Postgres/AgentExperience.Storage.Postgres.csproj b/src/AgentExperience.Storage.Postgres/AgentExperience.Storage.Postgres.csproj new file mode 100644 index 0000000..41c2c29 --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/AgentExperience.Storage.Postgres.csproj @@ -0,0 +1,30 @@ + + + + PostgreSQL adapter for AgentExperience.NET: persists canonical, scoped Experience Records through the IExperienceRecordStore port with plain Npgsql, host-authorization checks before any database access, exact scope predicates applied in SQL, and an embedded, versioned schema script. Pinned to Npgsql 10.0.3. + true + README.md + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AgentExperience.Storage.Postgres/ExperiencePayload.cs b/src/AgentExperience.Storage.Postgres/ExperiencePayload.cs new file mode 100644 index 0000000..1fff0a5 --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/ExperiencePayload.cs @@ -0,0 +1,304 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AgentExperience.Abstractions; + +namespace AgentExperience.Storage.Postgres; + +/// +/// The adapter-owned, versioned JSONB payload shape. Scope, status, confidence, counters, revision, +/// and timestamps live in their own columns; everything else lives here. Domain types carry no +/// version field -- this adapter maps them to and from explicit DTOs so a domain rename never +/// silently changes stored JSON. +/// +internal static class ExperiencePayload +{ + /// The payload version written by this adapter. + public const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + RespectNullableAnnotations = true, + RespectRequiredConstructorParameters = true, + Converters = { new JsonStringEnumConverter(allowIntegerValues: false) }, + }; + + public static string Serialize(ExperienceRecord record) => + JsonSerializer.Serialize(ToV1(record), SerializerOptions); + + public static PayloadV1 Deserialize(int version, string json) + { + if (version != CurrentVersion) + { + throw new ExperienceStoreException( + $"Stored Experience Record has unsupported payload_version {version}; this adapter reads version {CurrentVersion}."); + } + + try + { + return JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new ExperienceStoreException("Stored Experience Record payload is null."); + } + catch (JsonException ex) + { + throw new ExperienceStoreException( + $"Stored Experience Record payload could not be read as payload_version {CurrentVersion}.", ex); + } + } + + private static PayloadV1 ToV1(ExperienceRecord record) => new( + record.TaskSummary, + record.Attempts.Select(a => new AttemptV1( + a.AttemptId, + a.SequenceNumber, + Utc(a.StartedAt), + a.Duration, + a.ToolCalls.Select(t => new ToolCallV1( + t.ToolCallId, + t.SequenceNumber, + t.ToolName, + t.Arguments, + Utc(t.StartedAt), + t.Duration, + t.Result, + t.Error)).ToList(), + a.Result, + a.Error)).ToList(), + new OutcomeV1( + record.Outcome.Status, + record.Outcome.Evidence.Select(e => new EvidenceV1( + e.EvidenceId, + e.VerificationRoundId, + e.ArtifactRevision, + e.CheckId, + e.Kind, + e.Result, + e.Producer, + e.Detail, + Utc(e.CapturedAt))).ToList(), + record.Outcome.Reason, + Utc(record.Outcome.EvaluatedAt)), + record.CompletionScore, + record.Reflection is null + ? null + : new ReflectionV1( + record.Reflection.ReflectionId, + record.Reflection.ExperienceRunId, + record.Reflection.Lesson, + record.Reflection.SuccessfulApproaches.ToList(), + record.Reflection.FailedApproaches.ToList(), + record.Reflection.Preconditions.ToList(), + record.Reflection.Warnings.ToList(), + record.Reflection.ReuseGuidance, + record.Reflection.EvidenceIds.ToList(), + record.Reflection.VerificationStatus, + record.Reflection.CompletionScore, + record.Reflection.VerificationRuleVersion, + record.Reflection.Producer, + Utc(record.Reflection.CreatedAt)), + new EnvironmentV1( + record.Environment.HostName, + record.Environment.RuntimeVersion, + record.Environment.OperatingSystem, + record.Environment.ApplicationVersion, + new Dictionary(record.Environment.Metadata, StringComparer.Ordinal)), + new ProvenanceV1( + record.Provenance.Source, + record.Provenance.SourceVersion, + Utc(record.Provenance.RecordedAt), + record.Provenance.CorrelationId)); + + /// Maps a stored payload plus its column values back to the domain record. + public static ExperienceRecord ToRecord( + PayloadV1 payload, + Guid experienceId, + Guid sourceRunId, + Scope scope, + string taskId, + ExperienceStatus status, + double reuseConfidence, + int supportingValidations, + int contradictions, + long revision, + DateTimeOffset createdAt, + DateTimeOffset updatedAt) => new( + experienceId, + sourceRunId, + scope, + taskId, + payload.TaskSummary, + payload.Attempts.Select(a => new Attempt( + a.AttemptId, + a.SequenceNumber, + Utc(a.StartedAt), + a.Duration, + a.ToolCalls.Select(t => new ToolCallRecord( + t.ToolCallId, + t.SequenceNumber, + t.ToolName, + NormalizeArguments(t.Arguments), + Utc(t.StartedAt), + t.Duration, + t.Result, + t.Error)).ToList(), + a.Result, + a.Error)).ToList(), + new Outcome( + payload.Outcome.Status, + payload.Outcome.Evidence.Select(e => new Evidence( + e.EvidenceId, + e.VerificationRoundId, + e.ArtifactRevision, + e.CheckId, + e.Kind, + e.Result, + e.Producer, + e.Detail, + Utc(e.CapturedAt))).ToList(), + payload.Outcome.Reason, + Utc(payload.Outcome.EvaluatedAt)), + payload.CompletionScore, + payload.Reflection is null + ? null + : new Reflection( + payload.Reflection.ReflectionId, + payload.Reflection.ExperienceRunId, + payload.Reflection.Lesson, + payload.Reflection.SuccessfulApproaches, + payload.Reflection.FailedApproaches, + payload.Reflection.Preconditions, + payload.Reflection.Warnings, + payload.Reflection.ReuseGuidance, + payload.Reflection.EvidenceIds, + payload.Reflection.VerificationStatus, + payload.Reflection.CompletionScore, + payload.Reflection.VerificationRuleVersion, + payload.Reflection.Producer, + Utc(payload.Reflection.CreatedAt)), + new EnvironmentFingerprint( + payload.Environment.HostName, + payload.Environment.RuntimeVersion, + payload.Environment.OperatingSystem, + payload.Environment.ApplicationVersion, + payload.Environment.Metadata), + new Provenance( + payload.Provenance.Source, + payload.Provenance.SourceVersion, + Utc(payload.Provenance.RecordedAt), + payload.Provenance.CorrelationId), + status, + reuseConfidence, + supportingValidations, + contradictions, + revision, + Utc(createdAt), + Utc(updatedAt)); + + private static DateTimeOffset Utc(DateTimeOffset value) => value.ToUniversalTime(); + + private static IReadOnlyDictionary NormalizeArguments(IReadOnlyDictionary arguments) + { + var normalized = new Dictionary(arguments.Count, StringComparer.Ordinal); + foreach (var (key, value) in arguments) + { + normalized[key] = NormalizeValue(value); + } + + return normalized; + } + + /// + /// Converts a deserialized into plain CLR values: , + /// , (integral numbers that fit), , + /// , and . + /// + internal static object? NormalizeValue(object? value) => value is JsonElement element ? NormalizeElement(element) : value; + + private static object? NormalizeElement(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + // Cast each branch to object: a bare long/double conditional would promote integers to double. + JsonValueKind.Number => element.TryGetInt64(out var integral) ? (object)integral : (object)element.GetDouble(), + JsonValueKind.Array => element.EnumerateArray().Select(NormalizeElement).ToList(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(p => p.Name, p => NormalizeElement(p.Value), StringComparer.Ordinal), + _ => throw new ExperienceStoreException("Stored tool-call argument has an unsupported JSON kind."), + }; + + internal sealed record PayloadV1( + string? TaskSummary, + IReadOnlyList Attempts, + OutcomeV1 Outcome, + double CompletionScore, + ReflectionV1? Reflection, + EnvironmentV1 Environment, + ProvenanceV1 Provenance); + + internal sealed record AttemptV1( + Guid AttemptId, + int SequenceNumber, + DateTimeOffset StartedAt, + TimeSpan Duration, + IReadOnlyList ToolCalls, + string? Result, + string? Error); + + internal sealed record ToolCallV1( + Guid ToolCallId, + int SequenceNumber, + string ToolName, + IReadOnlyDictionary Arguments, + DateTimeOffset StartedAt, + TimeSpan Duration, + string? Result, + string? Error); + + internal sealed record OutcomeV1( + TaskVerificationStatus Status, + IReadOnlyList Evidence, + string? Reason, + DateTimeOffset EvaluatedAt); + + internal sealed record EvidenceV1( + Guid EvidenceId, + Guid VerificationRoundId, + string ArtifactRevision, + string CheckId, + string Kind, + CheckResult Result, + string Producer, + string? Detail, + DateTimeOffset CapturedAt); + + internal sealed record ReflectionV1( + Guid ReflectionId, + Guid ExperienceRunId, + string Lesson, + IReadOnlyList SuccessfulApproaches, + IReadOnlyList FailedApproaches, + IReadOnlyList Preconditions, + IReadOnlyList Warnings, + string? ReuseGuidance, + IReadOnlyList EvidenceIds, + TaskVerificationStatus VerificationStatus, + double CompletionScore, + string VerificationRuleVersion, + string Producer, + DateTimeOffset CreatedAt); + + internal sealed record EnvironmentV1( + string HostName, + string RuntimeVersion, + string OperatingSystem, + string? ApplicationVersion, + IReadOnlyDictionary Metadata); + + internal sealed record ProvenanceV1( + string Source, + string? SourceVersion, + DateTimeOffset RecordedAt, + string? CorrelationId); +} diff --git a/src/AgentExperience.Storage.Postgres/ExperienceRecordValidator.cs b/src/AgentExperience.Storage.Postgres/ExperienceRecordValidator.cs new file mode 100644 index 0000000..1a68d5d --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/ExperienceRecordValidator.cs @@ -0,0 +1,311 @@ +using AgentExperience.Abstractions; + +namespace AgentExperience.Storage.Postgres; + +/// +/// Structural validation run before authorization and before any database access. Collects every +/// error (never stops at the first) with a field path and a content-free message. Rejects nulls in +/// non-nullable members so a stored payload is always readable back. +/// +internal static class ExperienceRecordValidator +{ + private const string Required = "is required."; + private const string NotBlank = "must not be empty or whitespace."; + + public static IReadOnlyList ValidateRecord(ExperienceRecord record) + { + var errors = new List(); + + if (record.ExperienceId == Guid.Empty) + { + errors.Add(new("ExperienceId", "must not be an empty GUID.")); + } + + if (record.SourceRunId == Guid.Empty) + { + errors.Add(new("SourceRunId", "must not be an empty GUID.")); + } + + ValidateScope(record.Scope, "Scope", errors); + RequireNotBlank(record.TaskId, "TaskId", errors); + ValidateAttempts(record.Attempts, errors); + ValidateOutcome(record.Outcome, errors); + RequireUnitInterval(record.CompletionScore, "CompletionScore", errors); + + if (record.Reflection is not null) + { + ValidateReflection(record.Reflection, errors); + } + + ValidateEnvironment(record.Environment, errors); + ValidateProvenance(record.Provenance, errors); + RequireDefined(record.Status, "Status", errors); + RequireUnitInterval(record.ReuseConfidence, "ReuseConfidence", errors); + + if (record.SupportingValidations < 0) + { + errors.Add(new("SupportingValidations", "must not be negative.")); + } + + if (record.Contradictions < 0) + { + errors.Add(new("Contradictions", "must not be negative.")); + } + + if (record.Revision < 0) + { + errors.Add(new("Revision", "must not be negative.")); + } + + return errors; + } + + public static IReadOnlyList ValidateGet(Scope scope, Guid experienceId) + { + var errors = new List(); + if (experienceId == Guid.Empty) + { + errors.Add(new("ExperienceId", "must not be an empty GUID.")); + } + + ValidateScope(scope, "Scope", errors); + return errors; + } + + public static IReadOnlyList ValidateQuery(ExperienceRecordQuery query) + { + var errors = new List(); + ValidateScope(query.Scope, "Scope", errors); + + if (query.Statuses is not null) + { + if (query.Statuses.Count == 0) + { + errors.Add(new("Statuses", "must be null (all statuses) or contain at least one status.")); + } + + for (var i = 0; i < query.Statuses.Count; i++) + { + RequireDefined(query.Statuses[i], $"Statuses[{i}]", errors); + } + } + + if (query.Limit is < ExperienceRecordQuery.MinLimit or > ExperienceRecordQuery.MaxLimit) + { + errors.Add(new("Limit", $"must be between {ExperienceRecordQuery.MinLimit} and {ExperienceRecordQuery.MaxLimit}.")); + } + + return errors; + } + + private static void ValidateScope(Scope? scope, string path, List errors) + { + if (scope is null) + { + errors.Add(new(path, Required)); + return; + } + + RequireNotBlank(scope.TenantId, $"{path}.TenantId", errors); + RequireNotBlank(scope.ApplicationId, $"{path}.ApplicationId", errors); + RequireNotBlank(scope.ProjectId, $"{path}.ProjectId", errors); + RequireNullOrNotBlank(scope.TeamId, $"{path}.TeamId", errors); + RequireNullOrNotBlank(scope.AgentId, $"{path}.AgentId", errors); + RequireNullOrNotBlank(scope.UserId, $"{path}.UserId", errors); + } + + private static void ValidateAttempts(IReadOnlyList? attempts, List errors) + { + if (attempts is null) + { + errors.Add(new("Attempts", Required)); + return; + } + + for (var i = 0; i < attempts.Count; i++) + { + var attemptPath = $"Attempts[{i}]"; + var attempt = attempts[i]; + if (attempt is null) + { + errors.Add(new(attemptPath, Required)); + continue; + } + + if (attempt.ToolCalls is null) + { + errors.Add(new($"{attemptPath}.ToolCalls", Required)); + continue; + } + + for (var j = 0; j < attempt.ToolCalls.Count; j++) + { + var toolPath = $"{attemptPath}.ToolCalls[{j}]"; + var toolCall = attempt.ToolCalls[j]; + if (toolCall is null) + { + errors.Add(new(toolPath, Required)); + continue; + } + + RequireNotNull(toolCall.ToolName, $"{toolPath}.ToolName", errors); + RequireNotNull(toolCall.Arguments, $"{toolPath}.Arguments", errors); + } + } + } + + private static void ValidateOutcome(Outcome? outcome, List errors) + { + if (outcome is null) + { + errors.Add(new("Outcome", Required)); + return; + } + + RequireDefined(outcome.Status, "Outcome.Status", errors); + + if (outcome.Evidence is null) + { + errors.Add(new("Outcome.Evidence", Required)); + return; + } + + for (var i = 0; i < outcome.Evidence.Count; i++) + { + var path = $"Outcome.Evidence[{i}]"; + var evidence = outcome.Evidence[i]; + if (evidence is null) + { + errors.Add(new(path, Required)); + continue; + } + + RequireNotNull(evidence.ArtifactRevision, $"{path}.ArtifactRevision", errors); + RequireNotNull(evidence.CheckId, $"{path}.CheckId", errors); + RequireNotNull(evidence.Kind, $"{path}.Kind", errors); + RequireDefined(evidence.Result, $"{path}.Result", errors); + RequireNotNull(evidence.Producer, $"{path}.Producer", errors); + } + } + + private static void ValidateReflection(Reflection reflection, List errors) + { + RequireNotNull(reflection.Lesson, "Reflection.Lesson", errors); + RequireStringList(reflection.SuccessfulApproaches, "Reflection.SuccessfulApproaches", errors); + RequireStringList(reflection.FailedApproaches, "Reflection.FailedApproaches", errors); + RequireStringList(reflection.Preconditions, "Reflection.Preconditions", errors); + RequireStringList(reflection.Warnings, "Reflection.Warnings", errors); + RequireNotNull(reflection.EvidenceIds, "Reflection.EvidenceIds", errors); + RequireDefined(reflection.VerificationStatus, "Reflection.VerificationStatus", errors); + RequireUnitInterval(reflection.CompletionScore, "Reflection.CompletionScore", errors); + RequireNotNull(reflection.VerificationRuleVersion, "Reflection.VerificationRuleVersion", errors); + RequireNotNull(reflection.Producer, "Reflection.Producer", errors); + } + + private static void ValidateEnvironment(EnvironmentFingerprint? environment, List errors) + { + if (environment is null) + { + errors.Add(new("Environment", Required)); + return; + } + + RequireNotNull(environment.HostName, "Environment.HostName", errors); + RequireNotNull(environment.RuntimeVersion, "Environment.RuntimeVersion", errors); + RequireNotNull(environment.OperatingSystem, "Environment.OperatingSystem", errors); + + if (environment.Metadata is null) + { + errors.Add(new("Environment.Metadata", Required)); + return; + } + + // The path deliberately omits the key: metadata keys are payload content. + if (environment.Metadata.Values.Any(value => value is null)) + { + errors.Add(new("Environment.Metadata", "must not contain null values.")); + } + } + + private static void ValidateProvenance(Provenance? provenance, List errors) + { + if (provenance is null) + { + errors.Add(new("Provenance", Required)); + return; + } + + RequireNotNull(provenance.Source, "Provenance.Source", errors); + } + + private static void RequireStringList(IReadOnlyList? values, string path, List errors) + { + if (values is null) + { + errors.Add(new(path, Required)); + return; + } + + for (var i = 0; i < values.Count; i++) + { + RequireNotNull(values[i], $"{path}[{i}]", errors); + } + } + + private static void RequireNotNull(object? value, string path, List errors) + { + if (value is null) + { + errors.Add(new(path, Required)); + } + } + + private static void RequireNotBlank(string? value, string path, List errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add(new(path, NotBlank)); + } + else + { + RequireNoNul(value, path, errors); + } + } + + private static void RequireNullOrNotBlank(string? value, string path, List errors) + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + { + errors.Add(new(path, "must be null or a non-blank value.")); + } + else if (value is not null) + { + RequireNoNul(value, path, errors); + } + } + + private static void RequireNoNul(string value, string path, List errors) + { + if (value.Contains('\0', StringComparison.Ordinal)) + { + errors.Add(new(path, "must not contain the NUL character (U+0000).")); + } + } + + private static void RequireUnitInterval(double value, string path, List errors) + { + if (!(value >= 0d && value <= 1d)) + { + errors.Add(new(path, "must be between 0 and 1 inclusive.")); + } + } + + private static void RequireDefined(TEnum value, string path, List errors) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(value)) + { + errors.Add(new(path, "is not a defined value.")); + } + } +} diff --git a/src/AgentExperience.Storage.Postgres/Migrations/0001_create_experience_records.sql b/src/AgentExperience.Storage.Postgres/Migrations/0001_create_experience_records.sql new file mode 100644 index 0000000..d119ec0 --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/Migrations/0001_create_experience_records.sql @@ -0,0 +1,42 @@ +-- AgentExperience.NET: initial Experience Record schema (payload_version 1). +-- Plain SQL with no journal table, so a DbUp-style migrator can run it unchanged. + +CREATE SCHEMA IF NOT EXISTS agent_experience; + +CREATE TABLE IF NOT EXISTS agent_experience.experience_records ( + experience_id uuid NOT NULL, + source_run_id uuid NOT NULL, + tenant_id text NOT NULL, + application_id text NOT NULL, + project_id text NOT NULL, + team_id text NULL, + agent_id text NULL, + user_id text NULL, + task_id text NOT NULL, + status text NOT NULL, + reuse_confidence double precision NOT NULL, + supporting_validations integer NOT NULL, + contradictions integer NOT NULL, + revision bigint NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + payload_version integer NOT NULL, + payload jsonb NOT NULL, + CONSTRAINT experience_records_pkey PRIMARY KEY (experience_id), + CONSTRAINT experience_records_experience_id_not_empty CHECK (experience_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CONSTRAINT experience_records_tenant_id_not_blank CHECK (tenant_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_application_id_not_blank CHECK (application_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_project_id_not_blank CHECK (project_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_team_id_not_blank CHECK (team_id IS NULL OR team_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_agent_id_not_blank CHECK (agent_id IS NULL OR agent_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_user_id_not_blank CHECK (user_id IS NULL OR user_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_task_id_not_blank CHECK (task_id ~ '[^[:space:]]'), + CONSTRAINT experience_records_reuse_confidence_range CHECK (reuse_confidence >= 0 AND reuse_confidence <= 1), + CONSTRAINT experience_records_supporting_validations_nonnegative CHECK (supporting_validations >= 0), + CONSTRAINT experience_records_contradictions_nonnegative CHECK (contradictions >= 0), + CONSTRAINT experience_records_revision_nonnegative CHECK (revision >= 0), + CONSTRAINT experience_records_payload_version_positive CHECK (payload_version > 0) +); + +CREATE INDEX IF NOT EXISTS ix_experience_records_scope + ON agent_experience.experience_records (tenant_id, application_id, project_id); diff --git a/src/AgentExperience.Storage.Postgres/PostgresExperienceRecordSchema.cs b/src/AgentExperience.Storage.Postgres/PostgresExperienceRecordSchema.cs new file mode 100644 index 0000000..e9d7bde --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/PostgresExperienceRecordSchema.cs @@ -0,0 +1,39 @@ +namespace AgentExperience.Storage.Postgres; + +/// +/// Access to the schema scripts embedded in this package. Until a migration runner ships, the host +/// applies these scripts itself, in order, before using +/// . Scripts are plain SQL with no journal table, so a +/// DbUp-style migrator can run them unchanged later. +/// +public static class PostgresExperienceRecordSchema +{ + /// The PostgreSQL schema that holds every AgentExperience.NET table. + public const string SchemaName = "agent_experience"; + + /// The initial script that creates the experience_records table. + public const string InitialScriptName = "0001_create_experience_records.sql"; + + private const string ResourcePrefix = "AgentExperience.Storage.Postgres.Migrations."; + + /// Every embedded script name, in the order they must be applied. + public static IReadOnlyList ScriptNames { get; } = [InitialScriptName]; + + /// Reads an embedded script's SQL text. + /// One of . + /// The script's SQL. + /// is not an embedded script. + public static string GetScript(string scriptName) + { + ArgumentNullException.ThrowIfNull(scriptName); + if (!ScriptNames.Contains(scriptName, StringComparer.Ordinal)) + { + throw new ArgumentException("Unknown schema script name.", nameof(scriptName)); + } + + using var stream = typeof(PostgresExperienceRecordSchema).Assembly.GetManifestResourceStream(ResourcePrefix + scriptName) + ?? throw new InvalidOperationException($"Embedded schema script '{scriptName}' is missing from the assembly."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/AgentExperience.Storage.Postgres/PostgresExperienceRecordStore.cs b/src/AgentExperience.Storage.Postgres/PostgresExperienceRecordStore.cs new file mode 100644 index 0000000..8103bad --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/PostgresExperienceRecordStore.cs @@ -0,0 +1,348 @@ +using System.Data.Common; +using System.Net.Sockets; +using AgentExperience.Abstractions; +using Npgsql; +using NpgsqlTypes; + +namespace AgentExperience.Storage.Postgres; + +/// +/// over PostgreSQL with plain Npgsql. Each operation validates +/// the request, checks it against the host-established , and only +/// then opens a connection and runs parameterized SQL whose predicates apply the exact scope. The +/// schema must already exist; see . +/// +/// +/// PostgreSQL timestamptz stores microseconds, so and +/// are truncated to whole microseconds (in UTC) on write. +/// Nested timestamps live in the JSONB payload at full precision and are also returned in UTC. +/// Tool-call argument values read back JSON-normalized: , , +/// , , , +/// of to , and +/// of . Dictionary key order is not preserved, and whole-number +/// doubles read back as . Query ties on CreatedAt are broken by PostgreSQL uuid +/// byte order, which differs from .NET comparison. +/// +public sealed class PostgresExperienceRecordStore : IExperienceRecordStore +{ + private const string Table = "agent_experience.experience_records"; + + private const string SelectColumns = + "experience_id, source_run_id, tenant_id, application_id, project_id, team_id, agent_id, user_id, task_id, " + + "status, reuse_confidence, supporting_validations, contradictions, revision, created_at, updated_at, " + + "payload_version, payload"; + + private const string ScopePredicate = + "tenant_id = @tenant_id AND application_id = @application_id AND project_id = @project_id " + + "AND team_id IS NOT DISTINCT FROM @team_id AND agent_id IS NOT DISTINCT FROM @agent_id " + + "AND user_id IS NOT DISTINCT FROM @user_id"; + + private const string InsertSql = + $"INSERT INTO {Table} ({SelectColumns}) VALUES (@experience_id, @source_run_id, @tenant_id, @application_id, " + + "@project_id, @team_id, @agent_id, @user_id, @task_id, @status, @reuse_confidence, @supporting_validations, " + + "@contradictions, @revision, @created_at, @updated_at, @payload_version, @payload)"; + + private const string GetSql = + $"SELECT {SelectColumns} FROM {Table} WHERE experience_id = @experience_id AND {ScopePredicate}"; + + private const string QuerySql = $"SELECT {SelectColumns} FROM {Table} WHERE {ScopePredicate}"; + + private const string QueryStatusPredicate = " AND status = ANY(@statuses)"; + + private const string QueryOrderAndLimit = " ORDER BY created_at DESC, experience_id LIMIT @limit"; + + private static readonly IReadOnlyList NoErrors = []; + + private readonly NpgsqlDataSource _dataSource; + + /// Creates a store over a host-owned data source. The store never disposes it. + /// The Npgsql data source to open connections from. + /// is . + public PostgresExperienceRecordStore(NpgsqlDataSource dataSource) + { + ArgumentNullException.ThrowIfNull(dataSource); + _dataSource = dataSource; + } + + /// + public async Task CreateAsync( + AuthorizationContext authorization, + ExperienceRecord record, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authorization); + ArgumentNullException.ThrowIfNull(record); + + var errors = ExperienceRecordValidator.ValidateRecord(record); + if (errors.Count > 0) + { + return new(ExperienceStoreOutcome.Invalid, errors); + } + + if (!authorization.Permits(record.Scope)) + { + return new(ExperienceStoreOutcome.Denied, NoErrors); + } + + string payload; + try + { + payload = ExperiencePayload.Serialize(record); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Any serialization failure (non-finite doubles, invalid UTF-16, cycles, throwing getters) + // is a malformed request, never an infrastructure failure. + return new( + ExperienceStoreOutcome.Invalid, + [new StoreValidationError("Attempts", "tool-call arguments could not be serialized to JSON.")]); + } + + if (ContainsEscapedNul(payload)) + { + return new( + ExperienceStoreOutcome.Invalid, + [new StoreValidationError("Payload", "must not contain the NUL character (U+0000), which PostgreSQL jsonb cannot store.")]); + } + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await using var command = _dataSource.CreateCommand(InsertSql); + var parameters = command.Parameters; + parameters.Add(new NpgsqlParameter("experience_id", record.ExperienceId)); + parameters.Add(new NpgsqlParameter("source_run_id", record.SourceRunId)); + AddScopeParameters(parameters, record.Scope); + parameters.Add(new NpgsqlParameter("task_id", record.TaskId)); + parameters.Add(new NpgsqlParameter("status", record.Status.ToString())); + parameters.Add(new NpgsqlParameter("reuse_confidence", record.ReuseConfidence)); + parameters.Add(new NpgsqlParameter("supporting_validations", record.SupportingValidations)); + parameters.Add(new NpgsqlParameter("contradictions", record.Contradictions)); + parameters.Add(new NpgsqlParameter("revision", record.Revision)); + parameters.Add(new NpgsqlParameter("created_at", ToStoredTimestamp(record.CreatedAt))); + parameters.Add(new NpgsqlParameter("updated_at", ToStoredTimestamp(record.UpdatedAt))); + parameters.Add(new NpgsqlParameter("payload_version", ExperiencePayload.CurrentVersion)); + parameters.Add(new NpgsqlParameter("payload", NpgsqlDbType.Jsonb) { TypedValue = payload }); + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return new(ExperienceStoreOutcome.Created, NoErrors); + } + catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation && !cancellationToken.IsCancellationRequested) + { + // Identical regardless of which scope owns the existing ID: no record data is revealed. + return new(ExperienceStoreOutcome.Conflict, NoErrors); + } + catch (Exception ex) when (IsInfrastructureFailure(ex, cancellationToken)) + { + throw Translate(ex, "create", cancellationToken); + } + } + + /// + public async Task GetAsync( + AuthorizationContext authorization, + Scope scope, + Guid experienceId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authorization); + ArgumentNullException.ThrowIfNull(scope); + + var errors = ExperienceRecordValidator.ValidateGet(scope, experienceId); + if (errors.Count > 0) + { + return new(ExperienceStoreOutcome.Invalid, null, errors); + } + + if (!authorization.Permits(scope)) + { + return new(ExperienceStoreOutcome.Denied, null, NoErrors); + } + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await using var command = _dataSource.CreateCommand(GetSql); + command.Parameters.Add(new NpgsqlParameter("experience_id", experienceId)); + AddScopeParameters(command.Parameters, scope); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + return new(ExperienceStoreOutcome.NotFound, null, NoErrors); + } + + return new(ExperienceStoreOutcome.Found, ReadRecord(reader), NoErrors); + } + catch (Exception ex) when (IsInfrastructureFailure(ex, cancellationToken)) + { + throw Translate(ex, "get", cancellationToken); + } + } + + /// + public async Task QueryAsync( + AuthorizationContext authorization, + ExperienceRecordQuery query, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authorization); + ArgumentNullException.ThrowIfNull(query); + + var errors = ExperienceRecordValidator.ValidateQuery(query); + if (errors.Count > 0) + { + return new(ExperienceStoreOutcome.Invalid, [], errors); + } + + if (!authorization.Permits(query.Scope)) + { + return new(ExperienceStoreOutcome.Denied, [], NoErrors); + } + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var sql = query.Statuses is null + ? QuerySql + QueryOrderAndLimit + : QuerySql + QueryStatusPredicate + QueryOrderAndLimit; + + await using var command = _dataSource.CreateCommand(sql); + AddScopeParameters(command.Parameters, query.Scope); + if (query.Statuses is not null) + { + var statuses = query.Statuses.Distinct().Select(s => s.ToString()).ToArray(); + command.Parameters.Add(new NpgsqlParameter("statuses", NpgsqlDbType.Array | NpgsqlDbType.Text) { TypedValue = statuses }); + } + + command.Parameters.Add(new NpgsqlParameter("limit", query.Limit)); + + var records = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + records.Add(ReadRecord(reader)); + } + + return new(ExperienceStoreOutcome.Found, records, NoErrors); + } + catch (Exception ex) when (IsInfrastructureFailure(ex, cancellationToken)) + { + throw Translate(ex, "query", cancellationToken); + } + } + + private static void AddScopeParameters(NpgsqlParameterCollection parameters, Scope scope) + { + parameters.Add(new NpgsqlParameter("tenant_id", NpgsqlDbType.Text) { TypedValue = scope.TenantId }); + parameters.Add(new NpgsqlParameter("application_id", NpgsqlDbType.Text) { TypedValue = scope.ApplicationId }); + parameters.Add(new NpgsqlParameter("project_id", NpgsqlDbType.Text) { TypedValue = scope.ProjectId }); + parameters.Add(NullableText("team_id", scope.TeamId)); + parameters.Add(NullableText("agent_id", scope.AgentId)); + parameters.Add(NullableText("user_id", scope.UserId)); + } + + private static NpgsqlParameter NullableText(string name, string? value) => + new(name, NpgsqlDbType.Text) { Value = value is null ? DBNull.Value : value }; + + private static DateTimeOffset ToStoredTimestamp(DateTimeOffset value) + { + var utcTicks = value.UtcTicks; + return new DateTimeOffset(utcTicks - (utcTicks % 10), TimeSpan.Zero); + } + + /// + /// Finds a JSON \u0000 escape whose backslash is not itself escaped (an odd run of backslashes). + /// + private static bool ContainsEscapedNul(string json) + { + const string Escape = "\\u0000"; + for (var index = json.IndexOf(Escape, StringComparison.Ordinal); index >= 0; index = json.IndexOf(Escape, index + 1, StringComparison.Ordinal)) + { + var backslashes = 0; + for (var i = index; i >= 0 && json[i] == '\\'; i--) + { + backslashes++; + } + + if (backslashes % 2 == 1) + { + return true; + } + } + + return false; + } + + private static ExperienceRecord ReadRecord(DbDataReader reader) + { + try + { + return DecodeRecord(reader); + } + catch (Exception ex) when (ex is not (ExperienceStoreException or OperationCanceledException or NpgsqlException)) + { + // Schema drift or a corrupt payload (e.g. InvalidCastException, a null array element). + throw new ExperienceStoreException("Stored Experience Record could not be decoded.", ex); + } + } + + private static ExperienceRecord DecodeRecord(DbDataReader reader) + { + var statusText = reader.GetString(9); + if (!Enum.TryParse(statusText, ignoreCase: false, out var status) || !Enum.IsDefined(status) + || !string.Equals(status.ToString(), statusText, StringComparison.Ordinal)) + { + throw new ExperienceStoreException("Stored Experience Record has an unrecognized status."); + } + + var payload = ExperiencePayload.Deserialize(reader.GetInt32(16), reader.GetString(17)); + + var scope = new Scope( + reader.GetString(2), + reader.GetString(3), + reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? null : reader.GetString(7)); + + return ExperiencePayload.ToRecord( + payload, + reader.GetGuid(0), + reader.GetGuid(1), + scope, + reader.GetString(8), + status, + reader.GetDouble(10), + reader.GetInt32(11), + reader.GetInt32(12), + reader.GetInt64(13), + reader.GetFieldValue(14), + reader.GetFieldValue(15)); + } + + /// + /// Driver, socket, and timeout failures are translated. An + /// caused by the caller's own token is not matched, so it propagates unwrapped with its stack. + /// + private static bool IsInfrastructureFailure(Exception ex, CancellationToken cancellationToken) => ex switch + { + OperationCanceledException => !cancellationToken.IsCancellationRequested, + NpgsqlException or SocketException or TimeoutException => true, + _ => false, + }; + + private static Exception Translate(Exception ex, string operation, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + // The caller cancelled while the driver reported a failure: surface cancellation, unwrapped. + return new OperationCanceledException("The Experience Record store operation was cancelled.", ex, cancellationToken); + } + + return new ExperienceStoreException($"Experience Record {operation} failed due to a storage infrastructure error.", ex); + } +} diff --git a/src/AgentExperience.Storage.Postgres/README.md b/src/AgentExperience.Storage.Postgres/README.md new file mode 100644 index 0000000..adfc60b --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/README.md @@ -0,0 +1,108 @@ +# AgentExperience.Storage.Postgres + +Stores AgentExperience.NET Experience Records in PostgreSQL through the `IExperienceRecordStore` port, using plain +Npgsql. + +Pinned to `Npgsql` **10.0.3** (exact). Integration tests run against PostgreSQL 16 (`pgvector/pgvector:pg16`) through +`Testcontainers.PostgreSql` 4.15.0. This package does not use EF Core, Dapper, Pgvector, or the pgvector extension. + +## Usage + +```csharp +using AgentExperience.Abstractions; +using AgentExperience.Storage.Postgres; +using Npgsql; + +await using var dataSource = NpgsqlDataSource.Create(connectionString); +IExperienceRecordStore store = new PostgresExperienceRecordStore(dataSource); + +// Established by the host from its own authentication and authorization. Never built from request input. +var authorization = new AuthorizationContext( + TenantId: "tenant-1", + PrincipalId: "svc-support-agent", + Roles: ["experience:write"], + IssuedAt: DateTimeOffset.UtcNow, + ProjectId: "support"); // optional bound: this caller may only touch the "support" project + +var created = await store.CreateAsync(authorization, record, cancellationToken); +var read = await store.GetAsync(authorization, record.Scope, record.ExperienceId, cancellationToken); +var page = await store.QueryAsync( + authorization, + new ExperienceRecordQuery(record.Scope, Statuses: [ExperienceStatus.Validated], Limit: 20), + cancellationToken); +``` + +The store never disposes the data source. The host owns it. + +## Trusted host boundary + +- `AuthorizationContext` is the authority, and `Scope` only selects within it. The host must build the context from + its own trusted identity and permission checks, never from model output or request payloads. +- `TenantId` must always match. A non-null bound (`ApplicationId`, `ProjectId`, `TeamId`, `AgentId`, `UserId`) must + equal the request scope's field exactly. A null bound leaves that field unrestricted. A scope outside the context + returns `Denied` before any connection opens. +- Scope matching in SQL is exact, ordinal, and case-sensitive. A null optional scope field matches only null + (`IS NOT DISTINCT FROM`) and never acts as a wildcard. Empty or whitespace scope values are `Invalid`. +- A get for an ID that exists in another scope returns `NotFound`, the same as a missing ID. A create with an + existing ID returns `Conflict` without record data, whichever scope owns the existing record. +- This store does not evaluate roles. Role-based decisions stay with the host. + +## Results and failures + +| Situation | Result | +| --- | --- | +| Saved | `Created` | +| Read (a query with no matches is still `Found`) | `Found` | +| ID missing, or in another scope | `NotFound` | +| Scope outside the authorization context | `Denied` (no connection opened) | +| Malformed request | `Invalid` with every `StoreValidationError(Path, Message)` (no connection opened) | +| ID already exists in any scope | `Conflict` (stored row unchanged) | +| Database or driver failure (`NpgsqlException`, `SocketException`, `TimeoutException`) | throws `ExperienceStoreException` with the original as `InnerException` | +| Stored row with an unsupported `payload_version` or an unreadable payload | throws `ExperienceStoreException` | +| Caller cancellation | throws `OperationCanceledException`, unwrapped | + +Validation messages never contain record payload content, and the store does not log. + +A create whose acknowledgement was lost (cancelled or timed out after PostgreSQL committed it) returns `Conflict` +when retried. After a `Conflict`, call `GetAsync` in your own scope to check whether the stored record is yours. + +## Schema + +The schema lives in the embedded script `Migrations/0001_create_experience_records.sql`. It creates the +`agent_experience` schema and the `experience_records` table: + +- Scope, task, status, confidence, counter, revision, and timestamp columns, with `CHECK` constraints for non-blank + scope and value ranges. +- A JSONB `payload` column for attempts, outcome, evidence, reflection, environment, and provenance. +- A `payload_version` column. This adapter owns versioning, so the domain types carry no version field. +- An index on `(tenant_id, application_id, project_id)`. + +No migration runner ships yet. Until one does, apply the scripts yourself, in order, before using the store: + +```csharp +foreach (var name in PostgresExperienceRecordSchema.ScriptNames) +{ + await using var command = dataSource.CreateCommand(PostgresExperienceRecordSchema.GetScript(name)); + await command.ExecuteNonQueryAsync(cancellationToken); +} +``` + +The script is plain SQL with no journal table and uses `IF NOT EXISTS`, so a DbUp-style migrator can later run it +unchanged. Applying it needs permission to create schemas and tables. The store itself only needs `INSERT` and +`SELECT` on `agent_experience.experience_records`. + +## Data semantics + +- **Create-only.** Each create is a single `INSERT`. Updates, deletes, and lifecycle events belong to later stories. +- **UTC timestamps.** Every timestamp is stored and returned in UTC. `CreatedAt` and `UpdatedAt` are columns, and + PostgreSQL keeps microsecond precision, so sub-microsecond ticks are truncated on write. Nested timestamps are + stored in the payload at full precision. +- **Tool-call arguments** are stored as JSON and read back normalized to `string`, `bool`, `long` (integers that + fit), `double`, `null`, `Dictionary`, or `List`. Dictionary key order is not preserved. + Whole-number doubles (for example `1.0`) are written as JSON integers, so they read back as `long`. Values that cannot be serialized to JSON (for + example `NaN`, infinities, or cyclic graphs) make the create `Invalid`. +- **Query order** is newest `CreatedAt` first, then `ExperienceId` in PostgreSQL `uuid` byte order, which differs + from .NET `Guid` comparison. `Limit` must be from 1 to 500 (default 50). + `Statuses` is either null (all statuses) or a non-empty list. +- PostgreSQL cannot store the NUL character (U+0000) in `text` or `jsonb`, so a record or scope containing it is + `Invalid` and never reaches the database. diff --git a/src/AgentExperience.Storage.Postgres/packages.lock.json b/src/AgentExperience.Storage.Postgres/packages.lock.json new file mode 100644 index 0000000..42ab5e8 --- /dev/null +++ b/src/AgentExperience.Storage.Postgres/packages.lock.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Npgsql": { + "type": "Direct", + "requested": "[10.0.3, 10.0.3]", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "agentexperience.abstractions": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/AgentExperience.Abstractions.Tests/ContractShapeTests.cs b/tests/AgentExperience.Abstractions.Tests/ContractShapeTests.cs index aaba5c1..06e918e 100644 --- a/tests/AgentExperience.Abstractions.Tests/ContractShapeTests.cs +++ b/tests/AgentExperience.Abstractions.Tests/ContractShapeTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; + namespace AgentExperience.Abstractions.Tests; /// @@ -226,13 +228,16 @@ public void AuthorizationContext_and_Scope_are_distinct_types_with_no_field_leve var authorizationProperties = typeof(AuthorizationContext).GetProperties().Select(p => p.Name).ToHashSet(); var scopeProperties = typeof(Scope).GetProperties().Select(p => p.Name).ToHashSet(); - // Distinct shapes: Scope's required project identity has no counterpart on - // AuthorizationContext, and AuthorizationContext's host-established grant has no - // counterpart on Scope. + // Distinct shapes: AuthorizationContext's host-established grant has no counterpart on + // Scope. AuthorizationContext may carry optional bounds with Scope's field names (Story 2.1), + // but they are nullable restrictions defaulting to null, never a required request identity. Assert.Contains("ApplicationId", scopeProperties); Assert.Contains("ProjectId", scopeProperties); - Assert.DoesNotContain("ApplicationId", authorizationProperties); - Assert.DoesNotContain("ProjectId", authorizationProperties); + foreach (var bound in new[] { "ApplicationId", "ProjectId", "TeamId", "AgentId", "UserId" }) + { + Assert.Contains(bound, authorizationProperties); + Assert.Equal(typeof(string), typeof(AuthorizationContext).GetProperty(bound)!.PropertyType); + } Assert.Contains("PrincipalId", authorizationProperties); Assert.Contains("Roles", authorizationProperties); @@ -244,4 +249,175 @@ public void AuthorizationContext_and_Scope_are_distinct_types_with_no_field_leve var scope = new Scope("tenant-1", "app-1", "project-1"); Assert.NotEqual(authorization, scope); } + + // Story 2.1: ExperienceRecord, the IExperienceRecordStore port, and AuthorizationContext.Permits. + private static readonly DateTimeOffset Now = new(2026, 9, 17, 10, 0, 0, TimeSpan.Zero); + + [Fact] + public void ExperienceRecord_carries_every_canonical_part_and_no_version_field() + { + var parameters = typeof(ExperienceRecord).GetConstructors().Single().GetParameters() + .Select(p => (p.Name, p.ParameterType)) + .ToList(); + + Assert.Equal( + new (string?, Type)[] + { + ("ExperienceId", typeof(Guid)), + ("SourceRunId", typeof(Guid)), + ("Scope", typeof(Scope)), + ("TaskId", typeof(string)), + ("TaskSummary", typeof(string)), + ("Attempts", typeof(IReadOnlyList)), + ("Outcome", typeof(Outcome)), + ("CompletionScore", typeof(double)), + ("Reflection", typeof(Reflection)), + ("Environment", typeof(EnvironmentFingerprint)), + ("Provenance", typeof(Provenance)), + ("Status", typeof(ExperienceStatus)), + ("ReuseConfidence", typeof(double)), + ("SupportingValidations", typeof(int)), + ("Contradictions", typeof(int)), + ("Revision", typeof(long)), + ("CreatedAt", typeof(DateTimeOffset)), + ("UpdatedAt", typeof(DateTimeOffset)), + }, + parameters); + + Assert.DoesNotContain(typeof(ExperienceRecord).GetProperties(), p => p.Name.Contains("Version", StringComparison.OrdinalIgnoreCase)); + Assert.All(typeof(ExperienceRecord).GetProperties(), p => Assert.True(p.SetMethod is null || p.SetMethod.ReturnParameter.GetRequiredCustomModifiers().Any(m => m.Name == "IsExternalInit"))); + + var nullability = new NullabilityInfoContext(); + var ctorParameters = typeof(ExperienceRecord).GetConstructors().Single().GetParameters().ToDictionary(p => p.Name!); + Assert.Equal(NullabilityState.Nullable, nullability.Create(ctorParameters["TaskSummary"]).WriteState); + Assert.Equal(NullabilityState.Nullable, nullability.Create(ctorParameters["Reflection"]).WriteState); + Assert.Equal(NullabilityState.NotNull, nullability.Create(ctorParameters["Outcome"]).WriteState); + } + + [Fact] + public void Store_port_operations_take_authorization_and_a_required_cancellation_token() + { + var methods = typeof(IExperienceRecordStore).GetMethods().OrderBy(m => m.Name, StringComparer.Ordinal).ToList(); + + Assert.Equal(["CreateAsync", "GetAsync", "QueryAsync"], methods.Select(m => m.Name)); + Assert.All(methods, method => + { + var parameters = method.GetParameters(); + Assert.Equal(typeof(AuthorizationContext), parameters[0].ParameterType); + Assert.Equal(typeof(CancellationToken), parameters[^1].ParameterType); + Assert.False(parameters[^1].HasDefaultValue); + }); + + Assert.Equal(typeof(Task), methods[0].ReturnType); + Assert.Equal([typeof(AuthorizationContext), typeof(ExperienceRecord), typeof(CancellationToken)], methods[0].GetParameters().Select(p => p.ParameterType)); + Assert.Equal(typeof(Task), methods[1].ReturnType); + Assert.Equal([typeof(AuthorizationContext), typeof(Scope), typeof(Guid), typeof(CancellationToken)], methods[1].GetParameters().Select(p => p.ParameterType)); + Assert.Equal(typeof(Task), methods[2].ReturnType); + Assert.Equal([typeof(AuthorizationContext), typeof(ExperienceRecordQuery), typeof(CancellationToken)], methods[2].GetParameters().Select(p => p.ParameterType)); + } + + [Fact] + public void Query_defaults_to_all_statuses_and_a_limit_of_50_within_1_to_500() + { + var query = new ExperienceRecordQuery(new Scope("t", "a", "p")); + + Assert.Null(query.Statuses); + Assert.Equal(50, query.Limit); + Assert.Equal(50, ExperienceRecordQuery.DefaultLimit); + Assert.Equal(1, ExperienceRecordQuery.MinLimit); + Assert.Equal(500, ExperienceRecordQuery.MaxLimit); + } + + [Fact] + public void Store_outcomes_results_and_exception_have_the_expected_shape() + { + Assert.Equal( + ["Created", "Found", "NotFound", "Denied", "Invalid", "Conflict"], + Enum.GetNames()); + + var error = new StoreValidationError("Scope.TenantId", "must not be empty or whitespace."); + Assert.Equal("Scope.TenantId", error.Path); + + var get = new ExperienceRecordGetResult(ExperienceStoreOutcome.NotFound, null, []); + Assert.Null(get.Record); + var query = new ExperienceRecordQueryResult(ExperienceStoreOutcome.Invalid, [], [error]); + Assert.Single(query.Errors); + var create = new ExperienceRecordCreateResult(ExperienceStoreOutcome.Conflict, []); + Assert.DoesNotContain(create.GetType().GetProperties(), p => p.PropertyType == typeof(ExperienceRecord)); + + var inner = new InvalidOperationException("driver"); + var exception = new ExperienceStoreException("storage failed", inner); + Assert.Same(inner, exception.InnerException); + Assert.IsAssignableFrom(new ExperienceStoreException("unsupported payload version")); + } + + [Fact] + public void AuthorizationContext_bounds_default_to_null_so_existing_call_sites_compile() + { + var authorization = new AuthorizationContext("tenant-1", "principal", [], Now); + + Assert.Null(authorization.ApplicationId); + Assert.Null(authorization.ProjectId); + Assert.Null(authorization.TeamId); + Assert.Null(authorization.AgentId); + Assert.Null(authorization.UserId); + } + + [Fact] + public void Unbounded_context_permits_any_scope_in_its_tenant() + { + var authorization = new AuthorizationContext("tenant-1", "principal", [], Now); + + Assert.True(authorization.Permits(new Scope("tenant-1", "app", "project"))); + Assert.True(authorization.Permits(new Scope("tenant-1", "other-app", "other-project", "team", "agent", "user"))); + } + + [Theory] + [InlineData("tenant-2")] + [InlineData("Tenant-1")] + [InlineData("tenant-1 ")] + public void Tenant_must_match_exactly(string requestTenant) + { + var authorization = new AuthorizationContext("tenant-1", "principal", [], Now); + + Assert.False(authorization.Permits(new Scope(requestTenant, "app", "project"))); + } + + [Fact] + public void Blank_authorized_tenant_permits_nothing() + { + Assert.False(new AuthorizationContext("", "principal", [], Now).Permits(new Scope("", "app", "project"))); + Assert.False(new AuthorizationContext(" ", "principal", [], Now).Permits(new Scope(" ", "app", "project"))); + } + + [Fact] + public void Each_non_null_bound_must_equal_the_scope_field_exactly() + { + var scope = new Scope("tenant-1", "app", "project", "team", "agent", "user"); + var baseline = new AuthorizationContext("tenant-1", "principal", [], Now); + + Assert.True((baseline with { ApplicationId = "app", ProjectId = "project", TeamId = "team", AgentId = "agent", UserId = "user" }).Permits(scope)); + + Assert.False((baseline with { ApplicationId = "other" }).Permits(scope)); + Assert.False((baseline with { ProjectId = "Project" }).Permits(scope)); + Assert.False((baseline with { TeamId = "other" }).Permits(scope)); + Assert.False((baseline with { AgentId = "other" }).Permits(scope)); + Assert.False((baseline with { UserId = "other" }).Permits(scope)); + } + + [Fact] + public void Non_null_bound_does_not_permit_a_null_scope_field() + { + var authorization = new AuthorizationContext("tenant-1", "principal", [], Now, TeamId: "team"); + + Assert.False(authorization.Permits(new Scope("tenant-1", "app", "project", TeamId: null))); + } + + [Fact] + public void Permits_rejects_a_null_scope() + { + var authorization = new AuthorizationContext("tenant-1", "principal", [], Now); + + Assert.Throws(() => authorization.Permits(null!)); + } } diff --git a/tests/AgentExperience.Storage.Postgres.Tests/AgentExperience.Storage.Postgres.Tests.csproj b/tests/AgentExperience.Storage.Postgres.Tests/AgentExperience.Storage.Postgres.Tests.csproj new file mode 100644 index 0000000..a65e49d --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/AgentExperience.Storage.Postgres.Tests.csproj @@ -0,0 +1,27 @@ + + + + false + true + + $(NoWarn);CS1591 + + + + + + + + + + + + + + + + + + + + diff --git a/tests/AgentExperience.Storage.Postgres.Tests/DependencyBoundaryTests.cs b/tests/AgentExperience.Storage.Postgres.Tests/DependencyBoundaryTests.cs new file mode 100644 index 0000000..43a321a --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/DependencyBoundaryTests.cs @@ -0,0 +1,62 @@ +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +namespace AgentExperience.Storage.Postgres.Tests; + +/// +/// Proves AgentExperience.Storage.Postgres uses plain Npgsql only: no MAF, EF Core, Dapper, +/// Pgvector, or model-provider dependency, in either its compiled references or its csproj. +/// +public class DependencyBoundaryTests +{ + private static readonly string[] Forbidden = + [ + "Microsoft.Agents", // Microsoft Agent Framework (MAF) + "Microsoft.EntityFrameworkCore", // EF Core + "Dapper", + "Pgvector", + "VectorData", + "Microsoft.Extensions.AI", // model-provider / AI abstractions + "Microsoft.SemanticKernel", + "OpenAI", + "Azure.AI", + "Anthropic", + ]; + + [Fact] + public void Storage_Postgres_does_not_reference_a_forbidden_assembly() + { + var referenced = typeof(PostgresExperienceRecordStore).Assembly.GetReferencedAssemblies(); + Assert.Contains(referenced, a => a.Name == "Npgsql"); + + foreach (var name in referenced.Select(a => a.Name ?? string.Empty)) + { + foreach (var forbidden in Forbidden) + { + Assert.False( + name.Contains(forbidden, StringComparison.OrdinalIgnoreCase), + $"AgentExperience.Storage.Postgres references '{name}', which matches forbidden dependency '{forbidden}'."); + } + } + } + + [Fact] + public void Storage_Postgres_csproj_declares_only_an_exact_Npgsql_pin() + { + var csprojPath = GetCsprojPath(); + Assert.True(File.Exists(csprojPath), $"Could not locate AgentExperience.Storage.Postgres.csproj at '{csprojPath}'."); + + var packages = XDocument.Load(csprojPath) + .Descendants("PackageReference") + .Select(e => (Include: e.Attribute("Include")?.Value ?? string.Empty, Version: e.Attribute("Version")?.Value)) + .ToList(); + + var npgsql = Assert.Single(packages); + Assert.Equal("Npgsql", npgsql.Include); + Assert.Equal("[10.0.3]", npgsql.Version); + } + + private static string GetCsprojPath([CallerFilePath] string testSourceFilePath = "") => + Path.GetFullPath(Path.Combine( + Path.GetDirectoryName(testSourceFilePath)!, "..", "..", "src", "AgentExperience.Storage.Postgres", "AgentExperience.Storage.Postgres.csproj")); +} diff --git a/tests/AgentExperience.Storage.Postgres.Tests/OfflineStoreTests.cs b/tests/AgentExperience.Storage.Postgres.Tests/OfflineStoreTests.cs new file mode 100644 index 0000000..67153e1 --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/OfflineStoreTests.cs @@ -0,0 +1,291 @@ +using static AgentExperience.Storage.Postgres.Tests.TestRecords; + +namespace AgentExperience.Storage.Postgres.Tests; + +/// +/// Tests that need no database: the store runs against a data source pointing at a closed port, so +/// any attempt to open a connection would throw. Invalid and Denied results prove no connection was +/// opened; the unavailable case proves infrastructure failures are wrapped. +/// +public sealed class OfflineStoreTests : IAsyncLifetime +{ + private readonly Npgsql.NpgsqlDataSource _dataSource = Unreachable(); + private PostgresExperienceRecordStore Store => new(_dataSource); + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() => await _dataSource.DisposeAsync(); + + [Fact] + public async Task Malformed_record_returns_Invalid_with_every_field_path_error_and_no_database_call() + { + var tenant = NewTenant(); + var record = Minimal(Scope(tenant)) with + { + ExperienceId = Guid.Empty, + TaskId = " ", + Scope = new Scope(tenant, "", "project-1", TeamId: " "), + ReuseConfidence = 1.5, + CompletionScore = double.NaN, + SupportingValidations = -1, + Contradictions = -2, + Revision = -3, + Status = (ExperienceStatus)999, + Attempts = [new Attempt(Guid.NewGuid(), 0, ColumnTime, TimeSpan.Zero, [null!], null, null)], + }; + + var result = await Store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + Assert.Equal( + new[] + { + "Attempts[0].ToolCalls[0]", "CompletionScore", "Contradictions", "ExperienceId", "ReuseConfidence", + "Revision", "Scope.ApplicationId", "Scope.TeamId", "Status", "SupportingValidations", "TaskId", + }, + result.Errors.Select(e => e.Path).Order(StringComparer.Ordinal)); + Assert.All(result.Errors, e => Assert.False(string.IsNullOrWhiteSpace(e.Message))); + } + + [Fact] + public async Task Null_nested_members_return_Invalid_with_every_field_path() + { + var tenant = NewTenant(); + var valid = Full(Scope(tenant)); + var evidence = valid.Outcome.Evidence[0] with { ArtifactRevision = null!, CheckId = null!, Kind = null!, Producer = null! }; + var record = valid with + { + Outcome = valid.Outcome with { Evidence = [null!, evidence] }, + Reflection = valid.Reflection! with + { + Lesson = null!, + SuccessfulApproaches = ["ok", null!], + Producer = null!, + VerificationRuleVersion = null!, + }, + Environment = valid.Environment with + { + HostName = null!, + Metadata = new Dictionary { ["region"] = null! }, + }, + Provenance = valid.Provenance with { Source = null! }, + }; + + var result = await Store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + Assert.Equal( + new[] + { + "Environment.HostName", "Environment.Metadata", + "Outcome.Evidence[0]", "Outcome.Evidence[1].ArtifactRevision", "Outcome.Evidence[1].CheckId", + "Outcome.Evidence[1].Kind", "Outcome.Evidence[1].Producer", + "Provenance.Source", + "Reflection.Lesson", "Reflection.Producer", "Reflection.SuccessfulApproaches[1]", "Reflection.VerificationRuleVersion", + }.Order(StringComparer.Ordinal), + result.Errors.Select(e => e.Path).Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task Undefined_query_status_returns_Invalid() + { + var tenant = NewTenant(); + + var result = await Store.QueryAsync( + Authorize(tenant), + new ExperienceRecordQuery(Scope(tenant), [(ExperienceStatus)999]), + CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + Assert.Equal(["Statuses[0]"], result.Errors.Select(e => e.Path)); + } + + [Fact] + public async Task NaN_tool_argument_returns_Invalid() + { + var tenant = NewTenant(); + var result = await Store.CreateAsync(Authorize(tenant), WithArgument(Minimal(Scope(tenant)), double.NaN), CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + Assert.Equal(["Attempts"], result.Errors.Select(e => e.Path)); + } + + [Fact] + public async Task Unserializable_self_referencing_tool_argument_returns_Invalid() + { + var tenant = NewTenant(); + var cycle = new List(); + cycle.Add(cycle); + + var result = await Store.CreateAsync(Authorize(tenant), WithArgument(Minimal(Scope(tenant)), cycle), CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + Assert.Equal(["Attempts"], result.Errors.Select(e => e.Path)); + } + + [Fact] + public async Task NUL_in_TaskSummary_returns_Invalid_without_echoing_content() + { + var tenant = NewTenant(); + var record = Minimal(Scope(tenant)) with { TaskSummary = "secret\0summary" }; + + var result = await Store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + var error = Assert.Single(result.Errors); + Assert.Equal("Payload", error.Path); + Assert.DoesNotContain("secret", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Escaped_backslash_followed_by_u0000_text_is_not_mistaken_for_NUL() + { + var tenant = NewTenant(); + var record = Minimal(Scope(tenant)) with { TaskSummary = "literal \\u0000 text" }; + + // Passes validation and the NUL check, so it reaches the unreachable database. + await Assert.ThrowsAsync(() => Store.CreateAsync(Authorize(tenant), record, CancellationToken.None)); + } + + [Fact] + public async Task NUL_in_scope_ProjectId_returns_Invalid() + { + var tenant = NewTenant(); + var scope = Scope(tenant, project: "project\0one"); + + var create = await Store.CreateAsync(Authorize(tenant), Minimal(scope), CancellationToken.None); + var query = await Store.QueryAsync(Authorize(tenant), new ExperienceRecordQuery(scope), CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, create.Outcome); + Assert.Equal(["Scope.ProjectId"], create.Errors.Select(e => e.Path)); + Assert.Equal(ExperienceStoreOutcome.Invalid, query.Outcome); + Assert.Equal(["Scope.ProjectId"], query.Errors.Select(e => e.Path)); + } + + private static ExperienceRecord WithArgument(ExperienceRecord record, object? value) => record with + { + Attempts = + [ + new Attempt( + Guid.NewGuid(), + 0, + ColumnTime, + TimeSpan.Zero, + [new ToolCallRecord(Guid.NewGuid(), 0, "tool", new Dictionary { ["value"] = value }, ColumnTime, TimeSpan.Zero, null, null)], + null, + null), + ], + }; + + [Fact] + public async Task Negative_confidence_and_blank_tenant_are_Invalid() + { + var record = Minimal(new Scope("\t", "app", "project")) with { ReuseConfidence = -0.01 }; + + var result = await Store.CreateAsync(Authorize("\t"), record, CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, result.Outcome); + Assert.Equal(["ReuseConfidence", "Scope.TenantId"], result.Errors.Select(e => e.Path).Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task Malformed_get_and_query_return_Invalid() + { + var tenant = NewTenant(); + + var get = await Store.GetAsync(Authorize(tenant), new Scope(tenant, "app", " "), Guid.Empty, CancellationToken.None); + var query = await Store.QueryAsync(Authorize(tenant), new ExperienceRecordQuery(Scope(tenant), [], Limit: 501), CancellationToken.None); + var zeroLimit = await Store.QueryAsync(Authorize(tenant), new ExperienceRecordQuery(Scope(tenant), Limit: 0), CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Invalid, get.Outcome); + Assert.Equal(["ExperienceId", "Scope.ProjectId"], get.Errors.Select(e => e.Path).Order(StringComparer.Ordinal)); + Assert.Equal(ExperienceStoreOutcome.Invalid, query.Outcome); + Assert.Equal(["Limit", "Statuses"], query.Errors.Select(e => e.Path).Order(StringComparer.Ordinal)); + Assert.Equal(["Limit"], zeroLimit.Errors.Select(e => e.Path)); + } + + [Fact] + public async Task Different_tenant_is_Denied_before_any_connection_opens() + { + var record = Minimal(Scope("tenant-b")); + var auth = Authorize("tenant-a"); + + var create = await Store.CreateAsync(auth, record, CancellationToken.None); + var get = await Store.GetAsync(auth, record.Scope, record.ExperienceId, CancellationToken.None); + var query = await Store.QueryAsync(auth, new ExperienceRecordQuery(record.Scope), CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Denied, create.Outcome); + Assert.Equal(ExperienceStoreOutcome.Denied, get.Outcome); + Assert.Null(get.Record); + Assert.Equal(ExperienceStoreOutcome.Denied, query.Outcome); + Assert.Empty(query.Records); + } + + [Fact] + public async Task Non_null_bound_that_differs_from_request_scope_is_Denied() + { + var tenant = NewTenant(); + var auth = Authorize(tenant) with { ProjectId = "project-1" }; + var scope = Scope(tenant, project: "project-2"); + + Assert.Equal(ExperienceStoreOutcome.Denied, (await Store.CreateAsync(auth, Minimal(scope), CancellationToken.None)).Outcome); + Assert.Equal(ExperienceStoreOutcome.Denied, (await Store.GetAsync(auth, scope, Guid.NewGuid(), CancellationToken.None)).Outcome); + Assert.Equal(ExperienceStoreOutcome.Denied, (await Store.QueryAsync(auth, new ExperienceRecordQuery(scope), CancellationToken.None)).Outcome); + + var caseOnly = Authorize(tenant) with { ProjectId = "PROJECT-1" }; + Assert.Equal(ExperienceStoreOutcome.Denied, (await Store.QueryAsync(caseOnly, new ExperienceRecordQuery(Scope(tenant)), CancellationToken.None)).Outcome); + + var teamBound = Authorize(tenant) with { TeamId = "t1" }; + Assert.Equal(ExperienceStoreOutcome.Denied, (await Store.QueryAsync(teamBound, new ExperienceRecordQuery(Scope(tenant, team: null)), CancellationToken.None)).Outcome); + } + + [Fact] + public async Task Unavailable_database_throws_ExperienceStoreException_with_the_original_failure() + { + var tenant = NewTenant(); + var auth = Authorize(tenant); + var record = Minimal(Scope(tenant)); + + var create = await Assert.ThrowsAsync(() => Store.CreateAsync(auth, record, CancellationToken.None)); + var get = await Assert.ThrowsAsync(() => Store.GetAsync(auth, record.Scope, record.ExperienceId, CancellationToken.None)); + var query = await Assert.ThrowsAsync(() => Store.QueryAsync(auth, new ExperienceRecordQuery(record.Scope), CancellationToken.None)); + + Assert.All([create, get, query], ex => Assert.IsAssignableFrom(ex.InnerException)); + } + + [Fact] + public async Task Pre_cancelled_token_throws_OperationCanceledException_not_a_store_exception() + { + var tenant = NewTenant(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var ex = await Assert.ThrowsAnyAsync( + () => Store.GetAsync(Authorize(tenant), Scope(tenant), Guid.NewGuid(), cts.Token)); + Assert.IsNotType(ex); + } + + [Fact] + public async Task Null_arguments_throw_ArgumentNullException() + { + var tenant = NewTenant(); + await Assert.ThrowsAsync(() => Store.CreateAsync(null!, Minimal(Scope(tenant)), CancellationToken.None)); + await Assert.ThrowsAsync(() => Store.CreateAsync(Authorize(tenant), null!, CancellationToken.None)); + await Assert.ThrowsAsync(() => Store.GetAsync(Authorize(tenant), null!, Guid.NewGuid(), CancellationToken.None)); + await Assert.ThrowsAsync(() => Store.QueryAsync(Authorize(tenant), null!, CancellationToken.None)); + Assert.Throws(() => new PostgresExperienceRecordStore(null!)); + } + + [Fact] + public void Embedded_schema_script_is_available_and_creates_the_versioned_table() + { + var sql = PostgresExperienceRecordSchema.GetScript(PostgresExperienceRecordSchema.InitialScriptName); + + Assert.Equal([PostgresExperienceRecordSchema.InitialScriptName], PostgresExperienceRecordSchema.ScriptNames); + Assert.Contains("CREATE SCHEMA IF NOT EXISTS agent_experience", sql, StringComparison.Ordinal); + Assert.Contains("payload_version", sql, StringComparison.Ordinal); + Assert.Contains("jsonb", sql, StringComparison.Ordinal); + Assert.DoesNotContain("vector", sql, StringComparison.OrdinalIgnoreCase); + Assert.Throws(() => PostgresExperienceRecordSchema.GetScript("9999_missing.sql")); + } +} diff --git a/tests/AgentExperience.Storage.Postgres.Tests/PostgresExperienceRecordStoreTests.cs b/tests/AgentExperience.Storage.Postgres.Tests/PostgresExperienceRecordStoreTests.cs new file mode 100644 index 0000000..1c988f6 --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/PostgresExperienceRecordStoreTests.cs @@ -0,0 +1,555 @@ +using Npgsql; +using static AgentExperience.Storage.Postgres.Tests.TestRecords; + +namespace AgentExperience.Storage.Postgres.Tests; + +/// +/// Integration tests against a real PostgreSQL 16 container. Each test uses its own random tenant, so +/// tests sharing the container never see each other's rows. +/// +[Collection(PostgresCollection.Name)] +public sealed class PostgresExperienceRecordStoreTests +{ + private readonly PostgresFixture _fixture; + private readonly PostgresExperienceRecordStore _store; + + public PostgresExperienceRecordStoreTests(PostgresFixture fixture) + { + _fixture = fixture; + _store = new PostgresExperienceRecordStore(fixture.DataSource); + } + + [Fact] + public async Task Fully_populated_record_round_trips_deep_equal_after_JSON_normalization() + { + var tenant = NewTenant(); + var scope = new Scope(tenant, "app-1", "project-1", "team-1", "agent-1", "user-1"); + var record = Full(scope); + + var created = await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + Assert.Equal(ExperienceStoreOutcome.Created, created.Outcome); + Assert.Empty(created.Errors); + + var read = await _store.GetAsync(Authorize(tenant), scope, record.ExperienceId, CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Found, read.Outcome); + Assert.Empty(read.Errors); + Assert.NotNull(read.Record); + Assert.Equal(Canonical(record), Canonical(read.Record)); + Assert.Equal(record.CreatedAt, read.Record.CreatedAt); + Assert.Equal(record.UpdatedAt, read.Record.UpdatedAt); + Assert.Equal(record.Attempts[0].StartedAt.UtcTicks, read.Record.Attempts[0].StartedAt.UtcTicks); + } + + [Fact] + public async Task Tool_call_arguments_read_back_as_normalized_CLR_values() + { + var tenant = NewTenant(); + var record = Full(Scope(tenant)); + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + var read = await _store.GetAsync(Authorize(tenant), record.Scope, record.ExperienceId, CancellationToken.None); + var arguments = read.Record!.Attempts[0].ToolCalls[0].Arguments; + + Assert.IsType(arguments["query"]); + Assert.Equal(3L, Assert.IsType(arguments["limit"])); + Assert.Equal(0.75, Assert.IsType(arguments["threshold"])); + Assert.True(Assert.IsType(arguments["exact"])); + Assert.Null(arguments["cursor"]); + var filters = Assert.IsType>(arguments["filters"]); + var tags = Assert.IsType>(filters["tags"]); + Assert.Equal(["a", 2L, false], tags); + } + + [Fact] + public async Task Whole_number_double_tool_argument_reads_back_as_long() + { + var tenant = NewTenant(); + var minimal = Minimal(Scope(tenant)); + var record = minimal with + { + Attempts = + [ + new Attempt(Guid.NewGuid(), 0, ColumnTime, TimeSpan.Zero, + [new ToolCallRecord(Guid.NewGuid(), 0, "tool", new Dictionary { ["whole"] = 1.0, ["fraction"] = 1.5 }, ColumnTime, TimeSpan.Zero, null, null)], + null, null), + ], + }; + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + var arguments = (await _store.GetAsync(Authorize(tenant), record.Scope, record.ExperienceId, CancellationToken.None)).Record!.Attempts[0].ToolCalls[0].Arguments; + + Assert.Equal(1L, Assert.IsType(arguments["whole"])); + Assert.Equal(1.5, Assert.IsType(arguments["fraction"])); + } + + [Fact] + public async Task Timestamps_are_returned_in_UTC_as_the_same_instant() + { + var tenant = NewTenant(); + var offsetTime = new DateTimeOffset(2026, 9, 17, 12, 0, 0, TimeSpan.FromHours(2)).AddTicks(10); + var record = Minimal(Scope(tenant), createdAt: offsetTime) with + { + Provenance = new Provenance("tests", null, offsetTime.AddTicks(3), null), + }; + + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + var read = (await _store.GetAsync(Authorize(tenant), record.Scope, record.ExperienceId, CancellationToken.None)).Record!; + + Assert.Equal(TimeSpan.Zero, read.CreatedAt.Offset); + Assert.Equal(TimeSpan.Zero, read.UpdatedAt.Offset); + Assert.Equal(TimeSpan.Zero, read.Provenance.RecordedAt.Offset); + Assert.Equal(TimeSpan.Zero, read.Outcome.EvaluatedAt.Offset); + Assert.Equal(offsetTime, read.CreatedAt); // DateTimeOffset equality compares instants + Assert.Equal(offsetTime.AddTicks(3), read.Provenance.RecordedAt); + } + + [Fact] + public async Task Column_timestamps_are_truncated_to_PostgreSQL_microsecond_precision() + { + var tenant = NewTenant(); + var record = Minimal(Scope(tenant), createdAt: ColumnTime.AddTicks(7)); + + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + var read = (await _store.GetAsync(Authorize(tenant), record.Scope, record.ExperienceId, CancellationToken.None)).Record!; + + Assert.Equal(ColumnTime, read.CreatedAt); + } + + [Fact] + public async Task Get_of_an_ID_in_another_project_or_team_is_NotFound_like_a_missing_ID() + { + var tenant = NewTenant(); + var auth = Authorize(tenant); + var record = Minimal(Scope(tenant, project: "project-1", team: "team-1")); + await _store.CreateAsync(auth, record, CancellationToken.None); + + var otherProject = await _store.GetAsync(auth, Scope(tenant, project: "project-2", team: "team-1"), record.ExperienceId, CancellationToken.None); + var otherTeam = await _store.GetAsync(auth, Scope(tenant, project: "project-1", team: "team-2"), record.ExperienceId, CancellationToken.None); + var noTeam = await _store.GetAsync(auth, Scope(tenant, project: "project-1"), record.ExperienceId, CancellationToken.None); + var missing = await _store.GetAsync(auth, record.Scope, Guid.NewGuid(), CancellationToken.None); + + Assert.All([otherProject, otherTeam, noTeam, missing], result => + { + Assert.Equal(ExperienceStoreOutcome.NotFound, result.Outcome); + Assert.Null(result.Record); + Assert.Empty(result.Errors); + }); + } + + [Fact] + public async Task Duplicate_ID_conflicts_identically_in_any_scope_and_leaves_the_stored_row_unchanged() + { + var tenant = NewTenant(); + var otherTenant = NewTenant(); + var original = Full(Scope(tenant)); + await _store.CreateAsync(Authorize(tenant), original, CancellationToken.None); + + var sameScope = await _store.CreateAsync( + Authorize(tenant), + Minimal(original.Scope, id: original.ExperienceId, status: ExperienceStatus.Revoked), + CancellationToken.None); + var foreignScope = await _store.CreateAsync( + Authorize(otherTenant), + Minimal(Scope(otherTenant), id: original.ExperienceId), + CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Conflict, sameScope.Outcome); + Assert.Empty(sameScope.Errors); + Assert.Equal(ExperienceStoreOutcome.Conflict, foreignScope.Outcome); + Assert.Empty(foreignScope.Errors); + + var read = await _store.GetAsync(Authorize(tenant), original.Scope, original.ExperienceId, CancellationToken.None); + Assert.Equal(Canonical(original), Canonical(read.Record!)); + var foreignRead = await _store.GetAsync(Authorize(otherTenant), Scope(otherTenant), original.ExperienceId, CancellationToken.None); + Assert.Equal(ExperienceStoreOutcome.NotFound, foreignRead.Outcome); + } + + [Fact] + public async Task Null_TeamId_matches_only_null_and_a_set_TeamId_matches_only_itself() + { + var tenant = NewTenant(); + var auth = Authorize(tenant); + var noTeam = Minimal(Scope(tenant, team: null)); + var team = Minimal(Scope(tenant, team: "t1")); + await _store.CreateAsync(auth, noTeam, CancellationToken.None); + await _store.CreateAsync(auth, team, CancellationToken.None); + + var nullQuery = await _store.QueryAsync(auth, new ExperienceRecordQuery(Scope(tenant, team: null)), CancellationToken.None); + var teamQuery = await _store.QueryAsync(auth, new ExperienceRecordQuery(Scope(tenant, team: "t1")), CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Found, nullQuery.Outcome); + Assert.Equal([noTeam.ExperienceId], nullQuery.Records.Select(r => r.ExperienceId)); + Assert.Equal([team.ExperienceId], teamQuery.Records.Select(r => r.ExperienceId)); + Assert.Equal(ExperienceStoreOutcome.NotFound, (await _store.GetAsync(auth, Scope(tenant, team: null), team.ExperienceId, CancellationToken.None)).Outcome); + } + + [Fact] + public async Task ProjectId_matching_is_case_sensitive() + { + var tenant = NewTenant(); + var auth = Authorize(tenant); + var lower = Minimal(Scope(tenant, project: "proj")); + var upper = Minimal(Scope(tenant, project: "Proj")); + await _store.CreateAsync(auth, lower, CancellationToken.None); + await _store.CreateAsync(auth, upper, CancellationToken.None); + + var lowerQuery = await _store.QueryAsync(auth, new ExperienceRecordQuery(Scope(tenant, project: "proj")), CancellationToken.None); + var upperQuery = await _store.QueryAsync(auth, new ExperienceRecordQuery(Scope(tenant, project: "Proj")), CancellationToken.None); + + Assert.Equal([lower.ExperienceId], lowerQuery.Records.Select(r => r.ExperienceId)); + Assert.Equal([upper.ExperienceId], upperQuery.Records.Select(r => r.ExperienceId)); + Assert.Equal(ExperienceStoreOutcome.NotFound, (await _store.GetAsync(auth, Scope(tenant, project: "PROJ"), lower.ExperienceId, CancellationToken.None)).Outcome); + } + + [Fact] + public async Task Query_never_crosses_tenants_or_optional_scope_fields() + { + var tenantA = NewTenant(); + var tenantB = NewTenant(); + await _store.CreateAsync(Authorize(tenantA), Minimal(Scope(tenantA)), CancellationToken.None); + await _store.CreateAsync(Authorize(tenantB), Minimal(Scope(tenantB)), CancellationToken.None); + var agentScoped = Minimal(new Scope(tenantA, "app-1", "project-1", AgentId: "agent-1")); + await _store.CreateAsync(Authorize(tenantA), agentScoped, CancellationToken.None); + + var result = await _store.QueryAsync(Authorize(tenantA), new ExperienceRecordQuery(Scope(tenantA)), CancellationToken.None); + + var only = Assert.Single(result.Records); + Assert.Equal(tenantA, only.Scope.TenantId); + Assert.Null(only.Scope.AgentId); + } + + [Fact] + public async Task Query_filters_by_status() + { + var tenant = NewTenant(); + var auth = Authorize(tenant); + var candidate = Minimal(Scope(tenant), status: ExperienceStatus.Candidate); + var validated = Minimal(Scope(tenant), status: ExperienceStatus.Validated); + var reinforced = Minimal(Scope(tenant), status: ExperienceStatus.Reinforced); + foreach (var record in new[] { candidate, validated, reinforced }) + { + await _store.CreateAsync(auth, record, CancellationToken.None); + } + + var result = await _store.QueryAsync( + auth, + new ExperienceRecordQuery(Scope(tenant), [ExperienceStatus.Validated, ExperienceStatus.Reinforced, ExperienceStatus.Validated]), + CancellationToken.None); + var all = await _store.QueryAsync(auth, new ExperienceRecordQuery(Scope(tenant)), CancellationToken.None); + + Assert.Equal( + new[] { validated.ExperienceId, reinforced.ExperienceId }.Order(), + result.Records.Select(r => r.ExperienceId).Order()); + Assert.Equal(3, all.Records.Count); + } + + [Fact] + public async Task Query_applies_the_limit_newest_first() + { + var tenant = NewTenant(); + var auth = Authorize(tenant); + var records = Enumerable.Range(0, 5) + .Select(i => Minimal(Scope(tenant), createdAt: ColumnTime.AddMinutes(i))) + .ToList(); + foreach (var record in records) + { + await _store.CreateAsync(auth, record, CancellationToken.None); + } + + var result = await _store.QueryAsync(auth, new ExperienceRecordQuery(Scope(tenant), Limit: 2), CancellationToken.None); + + Assert.Equal([records[4].ExperienceId, records[3].ExperienceId], result.Records.Select(r => r.ExperienceId)); + } + + [Fact] + public async Task Bounded_authorization_permits_its_exact_project() + { + var tenant = NewTenant(); + var auth = Authorize(tenant) with { ProjectId = "project-1", TeamId = "t1" }; + var record = Minimal(Scope(tenant, project: "project-1", team: "t1")); + + Assert.Equal(ExperienceStoreOutcome.Created, (await _store.CreateAsync(auth, record, CancellationToken.None)).Outcome); + Assert.Equal(ExperienceStoreOutcome.Found, (await _store.GetAsync(auth, record.Scope, record.ExperienceId, CancellationToken.None)).Outcome); + } + + [Fact] + public async Task Unsupported_payload_version_throws_ExperienceStoreException_on_get_and_query() + { + var tenant = NewTenant(); + var record = Minimal(Scope(tenant)); + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + await using (var command = _fixture.DataSource.CreateCommand( + "UPDATE agent_experience.experience_records SET payload_version = 99 WHERE experience_id = @id")) + { + command.Parameters.Add(new NpgsqlParameter("id", record.ExperienceId)); + Assert.Equal(1, await command.ExecuteNonQueryAsync()); + } + + await Assert.ThrowsAsync( + () => _store.GetAsync(Authorize(tenant), record.Scope, record.ExperienceId, CancellationToken.None)); + await Assert.ThrowsAsync( + () => _store.QueryAsync(Authorize(tenant), new ExperienceRecordQuery(record.Scope), CancellationToken.None)); + } + + [Theory] + [InlineData("payload = '{}'::jsonb")] + [InlineData("payload = jsonb_set(payload, '{attempts}', '[null]'::jsonb)")] + [InlineData("status = '1'")] + [InlineData("status = 'validated'")] + public async Task Corrupt_stored_row_throws_ExperienceStoreException_on_get_and_query(string corruption) + { + var tenant = NewTenant(); + var record = Minimal(Scope(tenant), status: ExperienceStatus.Validated); + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + await using (var command = _fixture.DataSource.CreateCommand( + $"UPDATE agent_experience.experience_records SET {corruption} WHERE experience_id = @id")) + { + command.Parameters.Add(new NpgsqlParameter("id", record.ExperienceId)); + Assert.Equal(1, await command.ExecuteNonQueryAsync()); + } + + var get = await Assert.ThrowsAsync( + () => _store.GetAsync(Authorize(tenant), record.Scope, record.ExperienceId, CancellationToken.None)); + var query = await Assert.ThrowsAsync( + () => _store.QueryAsync(Authorize(tenant), new ExperienceRecordQuery(record.Scope), CancellationToken.None)); + Assert.Equal(get.GetType(), query.GetType()); + } + + [Fact] + public async Task Hand_written_payload_version_1_row_maps_field_by_field() + { + // Golden fixture: pins the stored v1 JSON keys, enum member names, and column values. If this + // breaks, existing rows break too -- add a new payload version instead of renaming. + var tenant = NewTenant(); + var experienceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + const string payload = """ + { + "taskSummary": "Resolve refund ticket", + "attempts": [ + { + "attemptId": "22222222-2222-2222-2222-222222222222", + "sequenceNumber": 0, + "startedAt": "2026-09-17T09:00:00.1234567+00:00", + "duration": "00:00:01.5000000", + "toolCalls": [ + { + "toolCallId": "33333333-3333-3333-3333-333333333333", + "sequenceNumber": 0, + "toolName": "search_docs", + "arguments": { "query": "refund", "limit": 3, "threshold": 0.5, "exact": true, "cursor": null, "tags": ["a", 1.0], "filter": { "lang": "en" } }, + "startedAt": "2026-09-17T09:00:00.2000000+00:00", + "duration": "00:00:00.1000000", + "result": "3 documents", + "error": null + } + ], + "result": null, + "error": "System.TimeoutException" + } + ], + "outcome": { + "status": "Verified", + "evidence": [ + { + "evidenceId": "44444444-4444-4444-4444-444444444444", + "verificationRoundId": "55555555-5555-5555-5555-555555555555", + "artifactRevision": "rev-7", + "checkId": "unit-tests-pass", + "kind": "TestResult", + "result": "Pass", + "producer": "ci", + "detail": "42 of 42 passed", + "capturedAt": "2026-09-17T09:01:00+00:00" + } + ], + "reason": "all checks passed", + "evaluatedAt": "2026-09-17T09:02:00+00:00" + }, + "completionScore": 1, + "reflection": { + "reflectionId": "66666666-6666-6666-6666-666666666666", + "experienceRunId": "77777777-7777-7777-7777-777777777777", + "lesson": "Retry after the lock clears.", + "successfulApproaches": ["retry"], + "failedApproaches": ["immediate update"], + "preconditions": ["ticket system reachable"], + "warnings": ["lock duration unknown"], + "reuseGuidance": "Ticket-lock failures only.", + "evidenceIds": ["44444444-4444-4444-4444-444444444444"], + "verificationStatus": "Verified", + "completionScore": 0.75, + "verificationRuleVersion": "v1", + "producer": "template-reflector/1.0", + "createdAt": "2026-09-17T09:03:00+00:00" + }, + "environment": { + "hostName": "worker-01", + "runtimeVersion": "10.0.0", + "operatingSystem": "linux-x64", + "applicationVersion": "1.2.3", + "metadata": { "region": "us-east" } + }, + "provenance": { + "source": "AgentExperience.MicrosoftAgentFramework", + "sourceVersion": "1.0.0", + "recordedAt": "2026-09-17T09:04:00+00:00", + "correlationId": "trace-123" + } + } + """; + + await using (var command = _fixture.DataSource.CreateCommand( + "INSERT INTO agent_experience.experience_records (experience_id, source_run_id, tenant_id, application_id, project_id, " + + "team_id, agent_id, user_id, task_id, status, reuse_confidence, supporting_validations, contradictions, revision, " + + "created_at, updated_at, payload_version, payload) VALUES (@id, '77777777-7777-7777-7777-777777777777', @tenant, " + + "'app-1', 'project-1', 'team-1', NULL, 'user-1', 'support-ticket', 'Reinforced', 0.8, 4, 1, 3, " + + "'2026-09-17T10:00:00.123456Z', '2026-09-17T11:00:00Z', 1, @payload::jsonb) " + + "ON CONFLICT (experience_id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id")) + { + command.Parameters.Add(new NpgsqlParameter("id", experienceId)); + command.Parameters.Add(new NpgsqlParameter("tenant", tenant)); + command.Parameters.Add(new NpgsqlParameter("payload", payload)); + await command.ExecuteNonQueryAsync(); + } + + var scope = new Scope(tenant, "app-1", "project-1", "team-1", null, "user-1"); + var result = await _store.GetAsync(Authorize(tenant), scope, experienceId, CancellationToken.None); + + Assert.Equal(ExperienceStoreOutcome.Found, result.Outcome); + var r = result.Record!; + Assert.Equal(experienceId, r.ExperienceId); + Assert.Equal(Guid.Parse("77777777-7777-7777-7777-777777777777"), r.SourceRunId); + Assert.Equal(scope, r.Scope); + Assert.Equal("support-ticket", r.TaskId); + Assert.Equal("Resolve refund ticket", r.TaskSummary); + Assert.Equal(ExperienceStatus.Reinforced, r.Status); + Assert.Equal(0.8, r.ReuseConfidence); + Assert.Equal(4, r.SupportingValidations); + Assert.Equal(1, r.Contradictions); + Assert.Equal(3L, r.Revision); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 10, 0, 0, TimeSpan.Zero).AddTicks(1_234_560), r.CreatedAt); + Assert.Equal(TimeSpan.Zero, r.CreatedAt.Offset); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 11, 0, 0, TimeSpan.Zero), r.UpdatedAt); + Assert.Equal(1d, r.CompletionScore); + + var attempt = Assert.Single(r.Attempts); + Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), attempt.AttemptId); + Assert.Equal(0, attempt.SequenceNumber); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 9, 0, 0, TimeSpan.Zero).AddTicks(1_234_567), attempt.StartedAt); + Assert.Equal(TimeSpan.FromMilliseconds(1500), attempt.Duration); + Assert.Null(attempt.Result); + Assert.Equal("System.TimeoutException", attempt.Error); + + var toolCall = Assert.Single(attempt.ToolCalls); + Assert.Equal(Guid.Parse("33333333-3333-3333-3333-333333333333"), toolCall.ToolCallId); + Assert.Equal(0, toolCall.SequenceNumber); + Assert.Equal("search_docs", toolCall.ToolName); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 9, 0, 0, 200, TimeSpan.Zero), toolCall.StartedAt); + Assert.Equal(TimeSpan.FromMilliseconds(100), toolCall.Duration); + Assert.Equal("3 documents", toolCall.Result); + Assert.Null(toolCall.Error); + Assert.Equal("refund", toolCall.Arguments["query"]); + Assert.Equal(3L, Assert.IsType(toolCall.Arguments["limit"])); + Assert.Equal(0.5, Assert.IsType(toolCall.Arguments["threshold"])); + Assert.Equal(true, toolCall.Arguments["exact"]); + Assert.Null(toolCall.Arguments["cursor"]); + var tags = Assert.IsType>(toolCall.Arguments["tags"]); + Assert.Equal("a", tags[0]); + Assert.Equal(1.0, Assert.IsType(tags[1])); // a stored "1.0" literal stays double + Assert.Equal("en", Assert.IsType>(toolCall.Arguments["filter"])["lang"]); + + Assert.Equal(TaskVerificationStatus.Verified, r.Outcome.Status); + Assert.Equal("all checks passed", r.Outcome.Reason); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 9, 2, 0, TimeSpan.Zero), r.Outcome.EvaluatedAt); + var evidence = Assert.Single(r.Outcome.Evidence); + Assert.Equal(Guid.Parse("44444444-4444-4444-4444-444444444444"), evidence.EvidenceId); + Assert.Equal(Guid.Parse("55555555-5555-5555-5555-555555555555"), evidence.VerificationRoundId); + Assert.Equal("rev-7", evidence.ArtifactRevision); + Assert.Equal("unit-tests-pass", evidence.CheckId); + Assert.Equal("TestResult", evidence.Kind); + Assert.Equal(CheckResult.Pass, evidence.Result); + Assert.Equal("ci", evidence.Producer); + Assert.Equal("42 of 42 passed", evidence.Detail); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 9, 1, 0, TimeSpan.Zero), evidence.CapturedAt); + + var reflection = r.Reflection!; + Assert.Equal(Guid.Parse("66666666-6666-6666-6666-666666666666"), reflection.ReflectionId); + Assert.Equal(Guid.Parse("77777777-7777-7777-7777-777777777777"), reflection.ExperienceRunId); + Assert.Equal("Retry after the lock clears.", reflection.Lesson); + Assert.Equal(["retry"], reflection.SuccessfulApproaches); + Assert.Equal(["immediate update"], reflection.FailedApproaches); + Assert.Equal(["ticket system reachable"], reflection.Preconditions); + Assert.Equal(["lock duration unknown"], reflection.Warnings); + Assert.Equal("Ticket-lock failures only.", reflection.ReuseGuidance); + Assert.Equal([Guid.Parse("44444444-4444-4444-4444-444444444444")], reflection.EvidenceIds); + Assert.Equal(TaskVerificationStatus.Verified, reflection.VerificationStatus); + Assert.Equal(0.75, reflection.CompletionScore); + Assert.Equal("v1", reflection.VerificationRuleVersion); + Assert.Equal("template-reflector/1.0", reflection.Producer); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 9, 3, 0, TimeSpan.Zero), reflection.CreatedAt); + + Assert.Equal("worker-01", r.Environment.HostName); + Assert.Equal("10.0.0", r.Environment.RuntimeVersion); + Assert.Equal("linux-x64", r.Environment.OperatingSystem); + Assert.Equal("1.2.3", r.Environment.ApplicationVersion); + Assert.Equal("us-east", Assert.Single(r.Environment.Metadata).Value); + Assert.Equal("region", Assert.Single(r.Environment.Metadata).Key); + + Assert.Equal("AgentExperience.MicrosoftAgentFramework", r.Provenance.Source); + Assert.Equal("1.0.0", r.Provenance.SourceVersion); + Assert.Equal(new DateTimeOffset(2026, 9, 17, 9, 4, 0, TimeSpan.Zero), r.Provenance.RecordedAt); + Assert.Equal("trace-123", r.Provenance.CorrelationId); + } + + [Fact] + public async Task Stored_rows_use_scope_status_and_version_columns() + { + var tenant = NewTenant(); + var record = Full(new Scope(tenant, "app-1", "project-1", "team-1", null, "user-1")); + await _store.CreateAsync(Authorize(tenant), record, CancellationToken.None); + + await using var command = _fixture.DataSource.CreateCommand( + "SELECT tenant_id, team_id, agent_id, status, reuse_confidence, revision, payload_version, payload ? 'attempts' " + + "FROM agent_experience.experience_records WHERE experience_id = @id"); + command.Parameters.Add(new NpgsqlParameter("id", record.ExperienceId)); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + + Assert.Equal(tenant, reader.GetString(0)); + Assert.Equal("team-1", reader.GetString(1)); + Assert.True(reader.IsDBNull(2)); + Assert.Equal("Validated", reader.GetString(3)); + Assert.Equal(2d / 3d, reader.GetDouble(4)); + Assert.Equal(3L, reader.GetInt64(5)); + Assert.Equal(1, reader.GetInt32(6)); + Assert.True(reader.GetBoolean(7)); + } + + [Fact] + public async Task Schema_rejects_blank_required_scope_even_when_bypassing_the_store() + { + await using var command = _fixture.DataSource.CreateCommand( + "INSERT INTO agent_experience.experience_records (experience_id, source_run_id, tenant_id, application_id, project_id, " + + "task_id, status, reuse_confidence, supporting_validations, contradictions, revision, created_at, updated_at, payload_version, payload) " + + "VALUES (gen_random_uuid(), gen_random_uuid(), ' ', 'app', 'proj', 'task', 'Candidate', 0, 0, 0, 0, now(), now(), 1, '{}')"); + + var ex = await Assert.ThrowsAsync(() => command.ExecuteNonQueryAsync()); + Assert.Equal(PostgresErrorCodes.CheckViolation, ex.SqlState); + } + + [Fact] + public async Task Caller_cancellation_surfaces_as_an_unwrapped_OperationCanceledException() + { + var tenant = NewTenant(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => _store.CreateAsync(Authorize(tenant), Minimal(Scope(tenant)), cts.Token)); + await Assert.ThrowsAnyAsync( + () => _store.QueryAsync(Authorize(tenant), new ExperienceRecordQuery(Scope(tenant)), cts.Token)); + } +} diff --git a/tests/AgentExperience.Storage.Postgres.Tests/PostgresFixture.cs b/tests/AgentExperience.Storage.Postgres.Tests/PostgresFixture.cs new file mode 100644 index 0000000..106d4d6 --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/PostgresFixture.cs @@ -0,0 +1,50 @@ +using AgentExperience.Storage.Postgres; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace AgentExperience.Storage.Postgres.Tests; + +/// +/// Starts one ephemeral pgvector/pgvector:pg16 container for the whole collection, applies the +/// package's embedded schema scripts in order, and tears the container down afterwards. Set +/// TESTCONTAINERS_RYUK_DISABLED=true if Ryuk fails under a local Docker setup. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private NpgsqlDataSource? _dataSource; + + public NpgsqlDataSource DataSource => _dataSource ?? throw new InvalidOperationException("Fixture not initialized."); + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder("pgvector/pgvector:pg16").Build(); + await _container.StartAsync(); + _dataSource = NpgsqlDataSource.Create(_container.GetConnectionString()); + + foreach (var scriptName in PostgresExperienceRecordSchema.ScriptNames) + { + await using var command = _dataSource.CreateCommand(PostgresExperienceRecordSchema.GetScript(scriptName)); + await command.ExecuteNonQueryAsync(); + } + } + + public async Task DisposeAsync() + { + if (_dataSource is not null) + { + await _dataSource.DisposeAsync(); + } + + if (_container is not null) + { + await _container.DisposeAsync(); + } + } +} + +[CollectionDefinition(Name)] +public sealed class PostgresCollection : ICollectionFixture +{ + public const string Name = "Postgres"; +} diff --git a/tests/AgentExperience.Storage.Postgres.Tests/TestRecords.cs b/tests/AgentExperience.Storage.Postgres.Tests/TestRecords.cs new file mode 100644 index 0000000..27012bf --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/TestRecords.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Npgsql; + +namespace AgentExperience.Storage.Postgres.Tests; + +internal static class TestRecords +{ + /// A microsecond-precise UTC instant, storable in a timestamptz column without truncation. + public static readonly DateTimeOffset ColumnTime = new DateTimeOffset(2026, 9, 17, 10, 0, 0, TimeSpan.Zero).AddTicks(1_234_560); + + /// A full 100 ns precision UTC instant for payload timestamps. + public static readonly DateTimeOffset PayloadTime = new DateTimeOffset(2026, 9, 17, 9, 0, 0, TimeSpan.Zero).AddTicks(7_654_321); + + public static string NewTenant() => "tenant-" + Guid.NewGuid().ToString("N"); + + public static Scope Scope(string tenant, string project = "project-1", string? team = null) => + new(tenant, "app-1", project, team, AgentId: null, UserId: null); + + public static AuthorizationContext Authorize(string tenant) => + new(tenant, "host-principal", ["experience:write"], ColumnTime); + + /// A data source pointing at a closed local port: any connection attempt fails fast. + public static NpgsqlDataSource Unreachable() => + NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Username=nobody;Password=nothing;Database=none;Timeout=3;Pooling=false"); + + public static ExperienceRecord Minimal(Scope scope, Guid? id = null, ExperienceStatus status = ExperienceStatus.Candidate, DateTimeOffset? createdAt = null) => new( + ExperienceId: id ?? Guid.NewGuid(), + SourceRunId: Guid.NewGuid(), + Scope: scope, + TaskId: "task-1", + TaskSummary: null, + Attempts: [], + Outcome: new Outcome(TaskVerificationStatus.Unknown, [], null, PayloadTime), + CompletionScore: 0, + Reflection: null, + Environment: new EnvironmentFingerprint("host", "10.0.0", "linux-x64", null, new Dictionary()), + Provenance: new Provenance("tests", null, PayloadTime, null), + Status: status, + ReuseConfidence: 0, + SupportingValidations: 0, + Contradictions: 0, + Revision: 0, + CreatedAt: createdAt ?? ColumnTime, + UpdatedAt: createdAt ?? ColumnTime); + + /// A record with every optional part populated, including nested tool-call argument shapes. + public static ExperienceRecord Full(Scope scope) + { + var evidenceId = Guid.NewGuid(); + var runId = Guid.NewGuid(); + return new ExperienceRecord( + ExperienceId: Guid.NewGuid(), + SourceRunId: runId, + Scope: scope, + TaskId: "support-ticket-resolution", + TaskSummary: "Resolve refund ticket", + Attempts: + [ + new Attempt( + Guid.NewGuid(), + 0, + PayloadTime, + TimeSpan.FromMilliseconds(1500.5), + [ + new ToolCallRecord( + Guid.NewGuid(), + 0, + "search_docs", + new Dictionary + { + ["query"] = "refund policy", + ["limit"] = 3L, + ["threshold"] = 0.75, + ["exact"] = true, + ["cursor"] = null, + ["filters"] = new Dictionary { ["lang"] = "en", ["tags"] = new List { "a", 2L, false } }, + }, + PayloadTime.AddTicks(1), + TimeSpan.FromTicks(123_456_789), + "3 documents", + null), + new ToolCallRecord(Guid.NewGuid(), 1, "update_ticket", new Dictionary(), PayloadTime.AddSeconds(1), TimeSpan.Zero, null, "System.TimeoutException"), + ], + null, + "could not update ticket"), + new Attempt(Guid.NewGuid(), 1, PayloadTime.AddSeconds(2), TimeSpan.FromSeconds(2), [], "ticket updated", null), + ], + Outcome: new Outcome( + TaskVerificationStatus.Verified, + [new Evidence(evidenceId, Guid.NewGuid(), "rev-7", "unit-tests-pass", "TestResult", CheckResult.Pass, "ci", "42 of 42 passed", PayloadTime)], + "all required checks passed", + PayloadTime.AddMinutes(1)), + CompletionScore: 1, + Reflection: new Reflection( + Guid.NewGuid(), + runId, + "Retry the ticket update after the lock clears.", + ["retry after lock"], + ["immediate update"], + ["ticket system reachable"], + ["lock duration unknown"], + "Use for ticket-lock failures only.", + [evidenceId], + TaskVerificationStatus.Verified, + 1, + "v1", + "template-reflector/1.0", + PayloadTime.AddMinutes(2)), + Environment: new EnvironmentFingerprint("worker-01", "10.0.0", "linux-x64", "1.2.3", new Dictionary { ["region"] = "us-east", ["az"] = "1b", ["a"] = "x" }), + Provenance: new Provenance("AgentExperience.MicrosoftAgentFramework", "1.0.0", PayloadTime, "trace-123"), + Status: ExperienceStatus.Validated, + ReuseConfidence: 2d / 3d, + SupportingValidations: 4, + Contradictions: 1, + Revision: 3, + CreatedAt: ColumnTime, + UpdatedAt: ColumnTime.AddSeconds(5)); + } + + /// + /// Canonical JSON with object keys sorted recursively, so records compare deep-equal after JSON + /// normalization regardless of dictionary key order or CLR numeric type. + /// + public static string Canonical(ExperienceRecord record) => Sort(JsonSerializer.SerializeToNode(record))?.ToJsonString() ?? "null"; + + private static JsonNode? Sort(JsonNode? node) => node switch + { + JsonObject obj => new JsonObject(obj.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => KeyValuePair.Create(p.Key, Sort(p.Value)))), + JsonArray array => new JsonArray(array.Select(Sort).ToArray()), + null => null, + _ => JsonNode.Parse(node.ToJsonString()), + }; +} diff --git a/tests/AgentExperience.Storage.Postgres.Tests/packages.lock.json b/tests/AgentExperience.Storage.Postgres.Tests/packages.lock.json new file mode 100644 index 0000000..f828891 --- /dev/null +++ b/tests/AgentExperience.Storage.Postgres.Tests/packages.lock.json @@ -0,0 +1,230 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.14.1, )", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.15.0, )", + "resolved": "4.15.0", + "contentHash": "45ZvqAzrh9BoI15Z4id7WEfhk1pVKqVeAX76aef8fR5ScglD75fSQtYuXhKFx4kIcgRL5aLKt3pRiqCVIgsL9g==", + "dependencies": { + "Testcontainers": "4.15.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.7.0", + "contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw==" + }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3", + "Docker.DotNet.Enhanced.LegacyHttp": "4.3.3", + "Docker.DotNet.Enhanced.NPipe": "4.3.3", + "Docker.DotNet.Enhanced.NativeHttp": "4.3.3", + "Docker.DotNet.Enhanced.Unix": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2026.0.0", + "contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==", + "dependencies": { + "BouncyCastle.Cryptography": "2.7.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.15.0", + "contentHash": "8tCZKMm++C/9dHIr8lsE1iDBIBmthbo2XlGFDr4gorT1vgZrJwU36fNkCGuX7h7V39rpQoTK/aG+FhJz3jhY3A==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.3.3", + "Docker.DotNet.Enhanced.X509": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2026.0.0", + "SharpZipLib": "1.4.2" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "agentexperience.abstractions": { + "type": "Project" + }, + "agentexperience.storage.postgres": { + "type": "Project", + "dependencies": { + "AgentExperience.Abstractions": "[1.0.0, )", + "Npgsql": "[10.0.3, 10.0.3]" + } + } + } + } +} \ No newline at end of file