wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -8,11 +8,22 @@ Provide PostgreSQL persistence for evidence records with tenant isolation.
- Ensure RLS/tenant scoping is enforced on every operation.
- Track task status in `TASKS.md`.
## DAL Technology
- **Primary**: EF Core v10 (converted from Npgsql/Dapper in Sprint 078).
- **Runtime factory**: `EvidenceDbContextFactory` applies compiled model for default schema.
- **Design-time factory**: `EvidenceDesignTimeDbContextFactory` for `dotnet ef` CLI.
- **Schema**: `evidence` (single migration: `001_initial_schema.sql`).
- **Migration registry**: Registered as `EvidenceMigrationModulePlugin` in Platform.Database.
## Required Reading
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/evidence/unified-model.md`
- `docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md`
- `docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md`
## Working Agreement
- 1. Update task status in the sprint file and local `TASKS.md`.
- 2. Prefer deterministic ordering and stable pagination.
- 3. Add tests for tenant isolation and migration behavior.
- 4. EF Core models are scaffolded FROM SQL migrations, never the reverse.
- 5. No EF Core auto-migrations at runtime.

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Evidence.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for EvidenceDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(Context.EvidenceDbContext))]
public partial class EvidenceDbContextModel : RuntimeModel
{
private static EvidenceDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new EvidenceDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -1,21 +1,72 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Evidence.Persistence.EfCore.Models;
namespace StellaOps.Evidence.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for Evidence module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// EF Core DbContext for the Evidence module.
/// Maps to the evidence PostgreSQL schema: records table.
/// </summary>
public class EvidenceDbContext : DbContext
public partial class EvidenceDbContext : DbContext
{
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options)
private readonly string _schemaName;
public EvidenceDbContext(DbContextOptions<EvidenceDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "evidence"
: schemaName.Trim();
}
public virtual DbSet<EvidenceRecordEntity> Records { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("evidence");
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
// -- records -------------------------------------------------------
modelBuilder.Entity<EvidenceRecordEntity>(entity =>
{
entity.HasKey(e => e.EvidenceId).HasName("records_pkey");
entity.ToTable("records", schemaName);
// Index for subject-based queries (most common access pattern)
entity.HasIndex(e => new { e.SubjectNodeId, e.EvidenceType }, "idx_evidence_subject");
// Index for type-based queries with recency ordering
entity.HasIndex(e => new { e.EvidenceType, e.CreatedAt }, "idx_evidence_type")
.IsDescending(false, true);
// Index for tenant-based queries with recency ordering
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_evidence_tenant")
.IsDescending(false, true);
// Index for external CID lookups (partial index)
entity.HasIndex(e => e.ExternalCid, "idx_evidence_external_cid")
.HasFilter("(external_cid IS NOT NULL)");
entity.Property(e => e.EvidenceId).HasColumnName("evidence_id");
entity.Property(e => e.SubjectNodeId).HasColumnName("subject_node_id");
entity.Property(e => e.EvidenceType).HasColumnName("evidence_type");
entity.Property(e => e.Payload).HasColumnName("payload");
entity.Property(e => e.PayloadSchemaVer).HasColumnName("payload_schema_ver");
entity.Property(e => e.ExternalCid).HasColumnName("external_cid");
entity.Property(e => e.Provenance)
.HasColumnType("jsonb")
.HasColumnName("provenance");
entity.Property(e => e.Signatures)
.HasColumnType("jsonb")
.HasDefaultValueSql("'[]'::jsonb")
.HasColumnName("signatures");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Evidence.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for EF Core CLI tooling (scaffold, optimize, etc.).
/// </summary>
public sealed class EvidenceDesignTimeDbContextFactory : IDesignTimeDbContextFactory<EvidenceDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=evidence,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_EVIDENCE_EF_CONNECTION";
public EvidenceDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<EvidenceDbContext>()
.UseNpgsql(connectionString)
.Options;
return new EvidenceDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Evidence.Persistence.EfCore.Models;
/// <summary>
/// EF Core entity for evidence.records table.
/// Scaffolded from 001_initial_schema.sql.
/// </summary>
public partial class EvidenceRecordEntity
{
public string EvidenceId { get; set; } = null!;
public string SubjectNodeId { get; set; } = null!;
public short EvidenceType { get; set; }
public byte[] Payload { get; set; } = null!;
public string PayloadSchemaVer { get; set; } = null!;
public string? ExternalCid { get; set; }
public string Provenance { get; set; } = null!;
public string Signatures { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public Guid TenantId { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Evidence.Persistence.EfCore.CompiledModels;
using StellaOps.Evidence.Persistence.EfCore.Context;
namespace StellaOps.Evidence.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="EvidenceDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class EvidenceDbContextFactory
{
public static EvidenceDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? EvidenceDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<EvidenceDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, EvidenceDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(EvidenceDbContextModel.Instance);
}
return new EvidenceDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Evidence.Persistence.Postgres;
public sealed partial class PostgresEvidenceStore
@@ -7,23 +9,15 @@ public sealed partial class PostgresEvidenceStore
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
const string sql = """
SELECT COUNT(*)
FROM evidence.records
WHERE subject_node_id = @subjectNodeId
AND tenant_id = @tenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
.ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var result = await ExecuteScalarAsync<long>(
_tenantId,
sql,
cmd =>
{
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
},
ct).ConfigureAwait(false);
var tenantGuid = Guid.Parse(_tenantId);
return (int)result;
return await dbContext.Records
.AsNoTracking()
.CountAsync(r => r.SubjectNodeId == subjectNodeId && r.TenantId == tenantGuid, ct)
.ConfigureAwait(false);
}
}

View File

@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Evidence.Persistence.Postgres;
public sealed partial class PostgresEvidenceStore
@@ -7,21 +9,16 @@ public sealed partial class PostgresEvidenceStore
{
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
const string sql = """
DELETE FROM evidence.records
WHERE evidence_id = @evidenceId
AND tenant_id = @tenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
.ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var affected = await ExecuteAsync(
_tenantId,
sql,
cmd =>
{
AddParameter(cmd, "@evidenceId", evidenceId);
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
},
ct).ConfigureAwait(false);
var tenantGuid = Guid.Parse(_tenantId);
var affected = await dbContext.Records
.Where(r => r.EvidenceId == evidenceId && r.TenantId == tenantGuid)
.ExecuteDeleteAsync(ct)
.ConfigureAwait(false);
return affected > 0;
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Evidence.Core;
namespace StellaOps.Evidence.Persistence.Postgres;
@@ -9,26 +10,20 @@ public sealed partial class PostgresEvidenceStore
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
const string sql = """
SELECT EXISTS(
SELECT 1 FROM evidence.records
WHERE subject_node_id = @subjectNodeId
AND evidence_type = @evidenceType
AND tenant_id = @tenantId
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
.ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var result = await ExecuteScalarAsync<bool>(
_tenantId,
sql,
cmd =>
{
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
AddParameter(cmd, "@evidenceType", (short)type);
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
},
ct).ConfigureAwait(false);
var tenantGuid = Guid.Parse(_tenantId);
var typeValue = (short)type;
return result;
return await dbContext.Records
.AsNoTracking()
.AnyAsync(r =>
r.SubjectNodeId == subjectNodeId &&
r.EvidenceType == typeValue &&
r.TenantId == tenantGuid,
ct)
.ConfigureAwait(false);
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Evidence.Core;
namespace StellaOps.Evidence.Persistence.Postgres;
@@ -9,23 +10,15 @@ public sealed partial class PostgresEvidenceStore
{
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId);
const string sql = """
SELECT evidence_id, subject_node_id, evidence_type, payload,
payload_schema_ver, external_cid, provenance, signatures
FROM evidence.records
WHERE evidence_id = @evidenceId
AND tenant_id = @tenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
.ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QuerySingleOrDefaultAsync<IEvidence>(
_tenantId,
sql,
cmd =>
{
AddParameter(cmd, "@evidenceId", evidenceId);
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
},
MapEvidence,
ct).ConfigureAwait(false);
var entity = await dbContext.Records
.AsNoTracking()
.FirstOrDefaultAsync(r => r.EvidenceId == evidenceId && r.TenantId == Guid.Parse(_tenantId), ct)
.ConfigureAwait(false);
return entity is null ? null : MapFromEntity(entity);
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Evidence.Core;
namespace StellaOps.Evidence.Persistence.Postgres;
@@ -12,34 +13,25 @@ public sealed partial class PostgresEvidenceStore
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectNodeId);
var sql = """
SELECT evidence_id, subject_node_id, evidence_type, payload,
payload_schema_ver, external_cid, provenance, signatures
FROM evidence.records
WHERE subject_node_id = @subjectNodeId
AND tenant_id = @tenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
.ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var tenantGuid = Guid.Parse(_tenantId);
var query = dbContext.Records
.AsNoTracking()
.Where(r => r.SubjectNodeId == subjectNodeId && r.TenantId == tenantGuid);
if (typeFilter.HasValue)
{
sql += " AND evidence_type = @evidenceType";
var typeValue = (short)typeFilter.Value;
query = query.Where(r => r.EvidenceType == typeValue);
}
sql += " ORDER BY created_at DESC";
query = query.OrderByDescending(r => r.CreatedAt);
return await QueryAsync<IEvidence>(
_tenantId,
sql,
cmd =>
{
AddParameter(cmd, "@subjectNodeId", subjectNodeId);
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
if (typeFilter.HasValue)
{
AddParameter(cmd, "@evidenceType", (short)typeFilter.Value);
}
},
MapEvidence,
ct).ConfigureAwait(false);
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Evidence.Core;
namespace StellaOps.Evidence.Persistence.Postgres;
@@ -10,26 +11,21 @@ public sealed partial class PostgresEvidenceStore
int limit = 100,
CancellationToken ct = default)
{
const string sql = """
SELECT evidence_id, subject_node_id, evidence_type, payload,
payload_schema_ver, external_cid, provenance, signatures
FROM evidence.records
WHERE evidence_type = @evidenceType
AND tenant_id = @tenantId
ORDER BY created_at DESC
LIMIT @limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "reader", ct)
.ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
return await QueryAsync<IEvidence>(
_tenantId,
sql,
cmd =>
{
AddParameter(cmd, "@evidenceType", (short)evidenceType);
AddParameter(cmd, "@tenantId", Guid.Parse(_tenantId));
AddParameter(cmd, "@limit", limit);
},
MapEvidence,
ct).ConfigureAwait(false);
var tenantGuid = Guid.Parse(_tenantId);
var typeValue = (short)evidenceType;
var entities = await dbContext.Records
.AsNoTracking()
.Where(r => r.EvidenceType == typeValue && r.TenantId == tenantGuid)
.OrderByDescending(r => r.CreatedAt)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
}

View File

@@ -1,38 +1,45 @@
using Npgsql;
using StellaOps.Evidence.Core;
using StellaOps.Evidence.Persistence.EfCore.Models;
using System.Text.Json;
namespace StellaOps.Evidence.Persistence.Postgres;
public sealed partial class PostgresEvidenceStore
{
private static IEvidence MapEvidence(NpgsqlDataReader reader)
private static IEvidence MapFromEntity(EvidenceRecordEntity entity)
{
var evidenceId = reader.GetString(0);
var subjectNodeId = reader.GetString(1);
var evidenceType = (EvidenceType)reader.GetInt16(2);
var payload = reader.GetFieldValue<byte[]>(3);
var payloadSchemaVer = reader.GetString(4);
var externalCid = GetNullableString(reader, 5);
var provenanceJson = reader.GetString(6);
var signaturesJson = reader.GetString(7);
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(entity.Provenance, _jsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {entity.EvidenceId}");
var provenance = JsonSerializer.Deserialize<EvidenceProvenance>(provenanceJson, _jsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize provenance for evidence {evidenceId}");
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(signaturesJson, _jsonOptions)
var signatures = JsonSerializer.Deserialize<List<EvidenceSignature>>(entity.Signatures, _jsonOptions)
?? [];
return new EvidenceRecord
{
EvidenceId = evidenceId,
SubjectNodeId = subjectNodeId,
EvidenceType = evidenceType,
Payload = payload,
PayloadSchemaVersion = payloadSchemaVer,
ExternalPayloadCid = externalCid,
EvidenceId = entity.EvidenceId,
SubjectNodeId = entity.SubjectNodeId,
EvidenceType = (EvidenceType)entity.EvidenceType,
Payload = entity.Payload,
PayloadSchemaVersion = entity.PayloadSchemaVer,
ExternalPayloadCid = entity.ExternalCid,
Provenance = provenance,
Signatures = signatures
};
}
private EvidenceRecordEntity MapToEntity(IEvidence evidence)
{
return new EvidenceRecordEntity
{
EvidenceId = evidence.EvidenceId,
SubjectNodeId = evidence.SubjectNodeId,
EvidenceType = (short)evidence.EvidenceType,
Payload = evidence.Payload.ToArray(),
PayloadSchemaVer = evidence.PayloadSchemaVersion,
ExternalCid = evidence.ExternalPayloadCid,
Provenance = JsonSerializer.Serialize(evidence.Provenance, _jsonOptions),
Signatures = JsonSerializer.Serialize(evidence.Signatures, _jsonOptions),
TenantId = Guid.Parse(_tenantId)
};
}
}

View File

@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Evidence.Core;
using System.Text.Json;
namespace StellaOps.Evidence.Persistence.Postgres;
@@ -12,43 +11,34 @@ public sealed partial class PostgresEvidenceStore
{
ArgumentNullException.ThrowIfNull(evidence);
const string sql = """
INSERT INTO evidence.records (
evidence_id, subject_node_id, evidence_type, payload,
payload_schema_ver, external_cid, provenance, signatures, tenant_id
) VALUES (
@evidenceId, @subjectNodeId, @evidenceType, @payload,
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
)
ON CONFLICT (evidence_id) DO NOTHING
RETURNING evidence_id
""";
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
AddEvidenceParameters(command, evidence);
var entity = MapToEntity(evidence);
dbContext.Records.Add(entity);
var result = await command.ExecuteScalarAsync(ct).ConfigureAwait(false);
try
{
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Row already existed (idempotent ON CONFLICT DO NOTHING equivalent)
}
// If result is null, row already existed (idempotent)
return evidence.EvidenceId;
}
private void AddEvidenceParameters(NpgsqlCommand command, IEvidence evidence)
private static bool IsUniqueViolation(DbUpdateException exception)
{
AddParameter(command, "@evidenceId", evidence.EvidenceId);
AddParameter(command, "@subjectNodeId", evidence.SubjectNodeId);
AddParameter(command, "@evidenceType", (short)evidence.EvidenceType);
command.Parameters.Add(new NpgsqlParameter<byte[]>("@payload", NpgsqlDbType.Bytea)
Exception? current = exception;
while (current is not null)
{
TypedValue = evidence.Payload.ToArray()
});
AddParameter(command, "@payloadSchemaVer", evidence.PayloadSchemaVersion);
AddParameter(command, "@externalCid", evidence.ExternalPayloadCid);
AddJsonbParameter(command, "@provenance", JsonSerializer.Serialize(evidence.Provenance, _jsonOptions));
AddJsonbParameter(command, "@signatures", JsonSerializer.Serialize(evidence.Signatures, _jsonOptions));
AddParameter(command, "@tenantId", Guid.Parse(_tenantId));
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Evidence.Core;
@@ -16,37 +17,28 @@ public sealed partial class PostgresEvidenceStore
return 0;
}
await using var connection = await DataSource.OpenConnectionAsync(_tenantId, "writer", ct)
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", ct)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
await using var dbContext = EvidenceDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var transaction = await dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
var storedCount = 0;
foreach (var evidence in records)
{
const string sql = """
INSERT INTO evidence.records (
evidence_id, subject_node_id, evidence_type, payload,
payload_schema_ver, external_cid, provenance, signatures, tenant_id
) VALUES (
@evidenceId, @subjectNodeId, @evidenceType, @payload,
@payloadSchemaVer, @externalCid, @provenance, @signatures, @tenantId
)
ON CONFLICT (evidence_id) DO NOTHING
""";
var entity = MapToEntity(evidence);
dbContext.Records.Add(entity);
await using var command = new NpgsqlCommand(sql, connection, transaction)
{
CommandTimeout = CommandTimeoutSeconds
};
AddEvidenceParameters(command, evidence);
var affected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
if (affected > 0)
try
{
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
storedCount++;
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Row already existed (idempotent); detach the entity to reset change tracker
dbContext.Entry(entity).State = EntityState.Detached;
}
}
await transaction.CommitAsync(ct).ConfigureAwait(false);

View File

@@ -1,17 +1,22 @@
using Microsoft.Extensions.Logging;
using StellaOps.Evidence.Core;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres.Connections;
using System.Text.Json;
namespace StellaOps.Evidence.Persistence.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IEvidenceStore"/>.
/// PostgreSQL (EF Core) implementation of <see cref="IEvidenceStore"/>.
/// Stores evidence records with content-addressed IDs and tenant isolation via RLS.
/// </summary>
public sealed partial class PostgresEvidenceStore : RepositoryBase<EvidenceDataSource>, IEvidenceStore
public sealed partial class PostgresEvidenceStore : IEvidenceStore
{
private const int CommandTimeoutSeconds = 30;
private readonly EvidenceDataSource _dataSource;
private readonly string _tenantId;
private readonly ILogger<PostgresEvidenceStore> _logger;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
@@ -27,9 +32,15 @@ public sealed partial class PostgresEvidenceStore : RepositoryBase<EvidenceDataS
EvidenceDataSource dataSource,
string tenantId,
ILogger<PostgresEvidenceStore> logger)
: base(dataSource, logger)
{
ArgumentNullException.ThrowIfNull(dataSource);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(logger);
_dataSource = dataSource;
_tenantId = tenantId;
_logger = logger;
}
private string GetSchemaName() => EvidenceDataSource.DefaultSchemaName;
}

View File

@@ -14,6 +14,11 @@
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\EvidenceDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />

View File

@@ -1,7 +1,7 @@
# Evidence Persistence Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260222_078_Evidence_persistence_dal_to_efcore.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
@@ -10,3 +10,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0081-A | TODO | Revalidated 2026-01-08 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | Split PostgresEvidenceStore into partials; dotnet test 2026-02-04 (35 tests). |
| EVID-EF-01 | DONE | AGENTS.md verified, migration plugin registered in Platform.Database. |
| EVID-EF-02 | DONE | EF Core DbContext and model scaffolded from 001_initial_schema.sql. |
| EVID-EF-03 | DONE | All repository partials converted from Npgsql to EF Core LINQ. |
| EVID-EF-04 | DONE | Compiled model stub, design-time factory, runtime factory with UseModel(). |
| EVID-EF-05 | DONE | Sequential builds pass (0 errors, 0 warnings). Docs/TASKS updated. |