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