feat(advisoryai): postgres runtime state cutover

Sprint SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.

- Migrations 009 ai_runtime_state + 010 advisory_ai_runtime_state_extensions.
- PostgresConversationService + PostgresAdvisoryChatSettingsStore.
- PostgresExplanationStore, PostgresPolicyIntentStore, PostgresRunStore,
  PostgresAiAttestationStore, PostgresAiConsentStore.
- Core + WebService runtime persistence extensions and program wiring.
- Chat integration + durable runtime tests.

Sub-sprint _022 (testing-only runtime fallback) follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-19 14:41:34 +03:00
parent a15405431b
commit 052de213e1
23 changed files with 2456 additions and 5 deletions

View File

@@ -12,7 +12,7 @@ AdvisoryAI provides AI-assisted security advisory analysis, remediation planning
- `opsmemory` (via Router) — decision recording, playbook suggestions, similarity-based recall
## Storage
PostgreSQL (OpsMemory: `ConnectionStrings:Default`; AdvisoryAI knowledge search: `KnowledgeSearch:ConnectionString`); file-system queue/plans/outputs for AdvisoryAI
PostgreSQL (OpsMemory: `ConnectionStrings:Default`; AdvisoryAI knowledge search: `KnowledgeSearch:ConnectionString`; AdvisoryAI consent + attestation runtime state: `AdvisoryAI:Storage:ConnectionString` -> `ConnectionStrings:Default` -> `Database:ConnectionString`, fail-fast outside Development/Testing); file-system queue/plans/outputs for AdvisoryAI
## Background Workers
- `KnowledgeSearchStartupRebuildService` — rebuilds knowledge search index on startup

View File

@@ -0,0 +1,111 @@
// <copyright file="AdvisoryAiCoreRuntimePersistenceExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Runs;
using StellaOps.AdvisoryAI.Storage;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AdvisoryAI.Hosting;
public static class AdvisoryAiCoreRuntimePersistenceExtensions
{
private const string StorageSectionName = "AdvisoryAI:Storage";
public static IServiceCollection AddAdvisoryAiCoreRuntimePersistence(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(environment);
var connectionString = ResolveConnectionString(configuration);
var allowInMemory = environment.IsEnvironment("Testing");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!allowInMemory)
{
throw new InvalidOperationException(
"AdvisoryAI requires PostgreSQL-backed runtime state outside Testing. Configure AdvisoryAI:Storage:ConnectionString, ConnectionStrings:Default, or Database:ConnectionString.");
}
return services;
}
services.AddPostgresOptions(configuration, StorageSectionName);
services.PostConfigure<PostgresOptions>(options =>
{
options.ConnectionString = string.IsNullOrWhiteSpace(options.ConnectionString)
? connectionString.Trim()
: options.ConnectionString.Trim();
options.SchemaName = string.IsNullOrWhiteSpace(options.SchemaName)
? AdvisoryAiDataSource.DefaultSchemaName
: options.SchemaName.Trim();
});
services.AddStartupMigrations<PostgresOptions>(
AdvisoryAiDataSource.DefaultSchemaName,
"AdvisoryAI",
typeof(AdvisoryAiDataSource).Assembly,
static options => options.ConnectionString);
services.TryAddSingleton<AdvisoryAiDataSource>();
services.RemoveAll<IConversationStore>();
services.AddSingleton<IConversationStore, ConversationStore>();
services.RemoveAll<IConversationService>();
services.AddSingleton<IConversationService, PostgresConversationService>();
services.RemoveAll<IExplanationStore>();
services.AddSingleton<PostgresExplanationStore>();
services.AddSingleton<IExplanationStore>(provider => provider.GetRequiredService<PostgresExplanationStore>());
services.AddSingleton<IExplanationRequestStore>(provider => provider.GetRequiredService<PostgresExplanationStore>());
services.RemoveAll<IPolicyIntentStore>();
services.AddSingleton<IPolicyIntentStore, PostgresPolicyIntentStore>();
services.RemoveAll<IRunStore>();
services.AddSingleton<IRunStore, PostgresRunStore>();
services.RemoveAll<IAdvisoryChatSettingsStore>();
services.AddSingleton<IAdvisoryChatSettingsStore, PostgresAdvisoryChatSettingsStore>();
return services;
}
public static string? ResolveConnectionString(IConfiguration configuration)
{
var explicitConnectionString = configuration[$"{StorageSectionName}:ConnectionString"];
if (!string.IsNullOrWhiteSpace(explicitConnectionString))
{
return explicitConnectionString.Trim();
}
var defaultConnectionString = configuration.GetConnectionString("Default");
if (!string.IsNullOrWhiteSpace(defaultConnectionString))
{
return defaultConnectionString.Trim();
}
var legacyConnectionString = configuration["Database:ConnectionString"];
return string.IsNullOrWhiteSpace(legacyConnectionString)
? null
: legacyConnectionString.Trim();
}
}

View File

@@ -86,12 +86,11 @@ builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IRateLimi
builder.Services.AddSingleton(TimeProvider.System);
// VEX-AI-016: Consent and justification services
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
// AI Attestations (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
builder.Services.AddAiAttestationServices();
builder.Services.AddInMemoryAiAttestationStore();
builder.Services.AddAdvisoryAiRuntimePersistence(builder.Configuration, builder.Environment);
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
builder.Services.AddEvidencePack();
@@ -1929,4 +1928,3 @@ namespace StellaOps.AdvisoryAI.WebService
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Storage;
using StellaOps.AdvisoryAI.Storage.Postgres;
namespace StellaOps.AdvisoryAI.WebService.Services;
/// <summary>
/// Registers authoritative AdvisoryAI runtime persistence for consent and attestation state.
/// </summary>
public static class AdvisoryAiRuntimePersistenceExtensions
{
public static IServiceCollection AddAdvisoryAiRuntimePersistence(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(environment);
services.AddAdvisoryAiCoreRuntimePersistence(configuration, environment);
var connectionString = AdvisoryAiCoreRuntimePersistenceExtensions.ResolveConnectionString(configuration);
var allowInMemory = environment.IsEnvironment("Testing");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!allowInMemory)
{
throw new InvalidOperationException(
"AdvisoryAI requires PostgreSQL-backed consent and attestation storage outside Testing. Configure AdvisoryAI:Storage:ConnectionString, ConnectionStrings:Default, or Database:ConnectionString.");
}
services.RemoveAll<IAiConsentStore>();
services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
services.RemoveAll<IAiAttestationStore>();
services.AddInMemoryAiAttestationStore();
return services;
}
services.RemoveAll<IAiConsentStore>();
services.AddSingleton<IAiConsentStore, PostgresAiConsentStore>();
services.RemoveAll<IAiAttestationStore>();
services.AddSingleton<IAiAttestationStore, PostgresAiAttestationStore>();
return services;
}
}

View File

@@ -0,0 +1,348 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AdvisoryAI.WebService.Services;
/// <summary>
/// PostgreSQL-backed attestation store for AdvisoryAI runtime state.
/// </summary>
public sealed class PostgresAiAttestationStore : RepositoryBase<AdvisoryAiDataSource>, IAiAttestationStore
{
public PostgresAiAttestationStore(
AdvisoryAiDataSource dataSource,
ILogger<PostgresAiAttestationStore> logger)
: base(dataSource, logger)
{
}
public Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(attestation);
const string sql = """
INSERT INTO ai_run_attestations (
run_id,
tenant_id,
started_at,
completed_at,
created_at,
content_digest,
attestation_json
) VALUES (
@run_id,
@tenant_id,
@started_at,
@completed_at,
@created_at,
@content_digest,
@attestation_json
)
ON CONFLICT (run_id) DO UPDATE
SET tenant_id = EXCLUDED.tenant_id,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
created_at = EXCLUDED.created_at,
content_digest = EXCLUDED.content_digest,
attestation_json = EXCLUDED.attestation_json;
""";
var attestationJson = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiRunAttestation);
var contentDigest = attestation.ComputeDigest();
return ExecuteAsync(
attestation.TenantId,
sql,
command =>
{
AddParameter(command, "run_id", attestation.RunId);
AddParameter(command, "tenant_id", attestation.TenantId);
AddParameter(command, "started_at", attestation.StartedAt);
AddParameter(command, "completed_at", attestation.CompletedAt);
AddParameter(command, "created_at", attestation.CompletedAt);
AddParameter(command, "content_digest", contentDigest);
AddJsonbParameter(command, "attestation_json", attestationJson);
},
ct);
}
public Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(envelope);
const string sql = """
UPDATE ai_run_attestations
SET signed_envelope_json = @signed_envelope_json
WHERE run_id = @run_id;
""";
var envelopeJson = SerializeEnvelope(envelope);
return ExecuteAsync(
string.Empty,
sql,
command =>
{
AddParameter(command, "run_id", runId.Trim());
AddJsonbParameter(command, "signed_envelope_json", envelopeJson);
},
ct);
}
public Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(attestation);
const string sql = """
INSERT INTO ai_claim_attestations (
claim_id,
run_id,
tenant_id,
turn_id,
content_digest,
"timestamp",
claim_json
) VALUES (
@claim_id,
@run_id,
@tenant_id,
@turn_id,
@content_digest,
@timestamp,
@claim_json
)
ON CONFLICT (claim_id) DO UPDATE
SET run_id = EXCLUDED.run_id,
tenant_id = EXCLUDED.tenant_id,
turn_id = EXCLUDED.turn_id,
content_digest = EXCLUDED.content_digest,
"timestamp" = EXCLUDED."timestamp",
claim_json = EXCLUDED.claim_json;
""";
var claimJson = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiClaimAttestation);
return ExecuteAsync(
attestation.TenantId,
sql,
command =>
{
AddParameter(command, "claim_id", attestation.ClaimId);
AddParameter(command, "run_id", attestation.RunId);
AddParameter(command, "tenant_id", attestation.TenantId);
AddParameter(command, "turn_id", attestation.TurnId);
AddParameter(command, "content_digest", attestation.ContentDigest);
AddParameter(command, "timestamp", attestation.Timestamp);
AddJsonbParameter(command, "claim_json", claimJson);
},
ct);
}
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
const string sql = """
SELECT attestation_json::text AS attestation_json
FROM ai_run_attestations
WHERE run_id = @run_id
LIMIT 1;
""";
return QuerySingleOrDefaultAsync(
string.Empty,
sql,
command => AddParameter(command, "run_id", runId.Trim()),
reader => DeserializeRunAttestation(reader.GetString(reader.GetOrdinal("attestation_json"))),
ct);
}
public Task<AiClaimAttestation?> GetClaimAttestationAsync(string claimId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(claimId);
const string sql = """
SELECT claim_json::text AS claim_json
FROM ai_claim_attestations
WHERE claim_id = @claim_id
LIMIT 1;
""";
return QuerySingleOrDefaultAsync(
string.Empty,
sql,
command => AddParameter(command, "claim_id", claimId.Trim()),
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
ct);
}
public async Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
const string sql = """
SELECT claim_json::text AS claim_json
FROM ai_claim_attestations
WHERE run_id = @run_id
ORDER BY "timestamp", claim_id;
""";
var results = await QueryAsync(
string.Empty,
sql,
command => AddParameter(command, "run_id", runId.Trim()),
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
ct).ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentException.ThrowIfNullOrWhiteSpace(turnId);
const string sql = """
SELECT claim_json::text AS claim_json
FROM ai_claim_attestations
WHERE run_id = @run_id
AND turn_id = @turn_id
ORDER BY "timestamp", claim_id;
""";
var results = await QueryAsync(
string.Empty,
sql,
command =>
{
AddParameter(command, "run_id", runId.Trim());
AddParameter(command, "turn_id", turnId.Trim());
},
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
ct).ConfigureAwait(false);
return results.ToImmutableArray();
}
public async Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
const string sql = """
SELECT signed_envelope_json::text AS signed_envelope_json
FROM ai_run_attestations
WHERE run_id = @run_id
LIMIT 1;
""";
var envelopeJson = await ExecuteScalarAsync<string>(
string.Empty,
sql,
command => AddParameter(command, "run_id", runId.Trim()),
ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(envelopeJson))
{
return null;
}
using var document = JsonDocument.Parse(envelopeJson);
return document.RootElement.Clone();
}
public async Task<bool> ExistsAsync(string runId, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
const string sql = """
SELECT EXISTS(
SELECT 1
FROM ai_run_attestations
WHERE run_id = @run_id
);
""";
return await ExecuteScalarAsync<bool>(
string.Empty,
sql,
command => AddParameter(command, "run_id", runId.Trim()),
ct).ConfigureAwait(false);
}
public async Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
const string sql = """
SELECT attestation_json::text AS attestation_json
FROM ai_run_attestations
WHERE tenant_id = @tenant_id
AND started_at >= @from
AND started_at <= @to
ORDER BY started_at, run_id;
""";
var results = await QueryAsync(
tenantId,
sql,
command =>
{
AddParameter(command, "tenant_id", tenantId.Trim());
AddParameter(command, "from", from);
AddParameter(command, "to", to);
},
reader => DeserializeRunAttestation(reader.GetString(reader.GetOrdinal("attestation_json"))),
ct).ConfigureAwait(false);
return results.ToImmutableArray();
}
public Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
const string sql = """
SELECT claim_json::text AS claim_json
FROM ai_claim_attestations
WHERE content_digest = @content_digest
LIMIT 1;
""";
return QuerySingleOrDefaultAsync(
string.Empty,
sql,
command => AddParameter(command, "content_digest", contentDigest.Trim()),
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
ct);
}
private static string SerializeEnvelope(object envelope)
{
return envelope switch
{
JsonElement jsonElement => jsonElement.GetRawText(),
JsonDocument jsonDocument => jsonDocument.RootElement.GetRawText(),
_ => JsonSerializer.Serialize(envelope),
};
}
private static AiRunAttestation DeserializeRunAttestation(string json)
=> JsonSerializer.Deserialize(json, AiAttestationJsonContext.Default.AiRunAttestation)
?? throw new InvalidOperationException("Stored AdvisoryAI run attestation payload could not be deserialized.");
private static AiClaimAttestation DeserializeClaimAttestation(string json)
=> JsonSerializer.Deserialize(json, AiAttestationJsonContext.Default.AiClaimAttestation)
?? throw new InvalidOperationException("Stored AdvisoryAI claim attestation payload could not be deserialized.");
}

View File

@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AdvisoryAI.WebService.Services;
/// <summary>
/// PostgreSQL-backed consent storage for AdvisoryAI runtime state.
/// </summary>
public sealed class PostgresAiConsentStore : RepositoryBase<AdvisoryAiDataSource>, IAiConsentStore
{
private readonly TimeProvider _timeProvider;
public PostgresAiConsentStore(
AdvisoryAiDataSource dataSource,
TimeProvider timeProvider,
ILogger<PostgresAiConsentStore> logger)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<AiConsentRecord?> GetConsentAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
const string sql = """
SELECT tenant_id, user_id, consented, scope, consented_at, expires_at, session_level
FROM ai_consents
WHERE tenant_id = @tenant_id
AND user_id = @user_id
AND (expires_at IS NULL OR expires_at >= @now)
LIMIT 1;
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
command =>
{
AddParameter(command, "tenant_id", tenantId.Trim());
AddParameter(command, "user_id", userId.Trim());
AddParameter(command, "now", _timeProvider.GetUtcNow());
},
MapConsentRecord,
cancellationToken).ConfigureAwait(false);
}
public async Task<AiConsentRecord> GrantConsentAsync(
string tenantId,
string userId,
AiConsentGrant grant,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
ArgumentNullException.ThrowIfNull(grant);
var now = _timeProvider.GetUtcNow();
var record = new AiConsentRecord
{
TenantId = tenantId.Trim(),
UserId = userId.Trim(),
Consented = true,
Scope = grant.Scope.Trim(),
ConsentedAt = now,
ExpiresAt = grant.Duration.HasValue ? now.Add(grant.Duration.Value) : null,
SessionLevel = grant.SessionLevel,
};
const string sql = """
INSERT INTO ai_consents (
tenant_id,
user_id,
consented,
scope,
consented_at,
expires_at,
session_level,
updated_at
) VALUES (
@tenant_id,
@user_id,
@consented,
@scope,
@consented_at,
@expires_at,
@session_level,
@updated_at
)
ON CONFLICT (tenant_id, user_id) DO UPDATE
SET consented = EXCLUDED.consented,
scope = EXCLUDED.scope,
consented_at = EXCLUDED.consented_at,
expires_at = EXCLUDED.expires_at,
session_level = EXCLUDED.session_level,
updated_at = EXCLUDED.updated_at;
""";
await ExecuteAsync(
record.TenantId,
sql,
command =>
{
AddParameter(command, "tenant_id", record.TenantId);
AddParameter(command, "user_id", record.UserId);
AddParameter(command, "consented", record.Consented);
AddParameter(command, "scope", record.Scope);
AddParameter(command, "consented_at", record.ConsentedAt);
AddParameter(command, "expires_at", record.ExpiresAt);
AddParameter(command, "session_level", record.SessionLevel);
AddParameter(command, "updated_at", now);
},
cancellationToken).ConfigureAwait(false);
Logger.LogInformation(
"Persisted AdvisoryAI consent for tenant {TenantId}, user {UserId}, scope {Scope}",
record.TenantId,
record.UserId,
record.Scope);
return record;
}
public async Task<bool> RevokeConsentAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
const string sql = """
DELETE FROM ai_consents
WHERE tenant_id = @tenant_id
AND user_id = @user_id;
""";
var affectedRows = await ExecuteAsync(
tenantId,
sql,
command =>
{
AddParameter(command, "tenant_id", tenantId.Trim());
AddParameter(command, "user_id", userId.Trim());
},
cancellationToken).ConfigureAwait(false);
return affectedRows > 0;
}
private static AiConsentRecord MapConsentRecord(Npgsql.NpgsqlDataReader reader)
{
var tenantId = reader.GetString(reader.GetOrdinal("tenant_id"));
var userId = reader.GetString(reader.GetOrdinal("user_id"));
var consented = reader.GetBoolean(reader.GetOrdinal("consented"));
var scope = reader.GetString(reader.GetOrdinal("scope"));
var consentedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("consented_at"));
var expiresAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("expires_at"));
var sessionLevel = reader.GetBoolean(reader.GetOrdinal("session_level"));
return new AiConsentRecord
{
TenantId = tenantId,
UserId = userId,
Consented = consented,
Scope = scope,
ConsentedAt = consentedAt,
ExpiresAt = expiresAt,
SessionLevel = sessionLevel,
};
}
}

View File

@@ -4,6 +4,9 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| AIAIRUNTIME-022 | DONE | `docs/implplan/SPRINT_20260417_022_AdvisoryAI_truthful_testing_only_runtime_fallback.md`: tightened AdvisoryAI runtime persistence fallback from `Development/Testing` to `Testing` only; `advisory-ai-web` now fails fast without the runtime DB contract in `Development`, while direct xUnit proof covers startup contract (`2/2`) and durable non-regression (`AdvisoryAiDurableRuntimeTests`, `6/6`). |
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: `advisory-ai-web` resolves explanation replay, policy intent, run, conversation, and chat-settings state through the shared PostgreSQL runtime persistence path; fallback wording was later tightened to `Testing` only in `AIAIRUNTIME-022`. |
| OPSREAL-003 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: `advisory-ai-web` binds `PostgresAiConsentStore` and `PostgresAiAttestationStore` through `AddAdvisoryAiRuntimePersistence` and auto-migrates `advisoryai` runtime tables; the environment gate was later tightened from `Development/Testing` to `Testing` only in `AIAIRUNTIME-022`. |
| SPRINT_20260405_010-AIAI-PG | DONE | `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md`: advisory-ai-web PostgreSQL attribution/pooling follow-up and Gitea spike capture. |
| SPRINT_20260222_051-AKS-API | DONE | Extended AKS search/open-action endpoint contract and added header-based authentication wiring (`AddAuthentication` + `AddAuthorization` + `UseAuthorization`) so `RequireAuthorization()` endpoints execute without runtime middleware errors. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |

View File

@@ -14,6 +14,7 @@ builder.Configuration
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
builder.Services.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddAdvisoryAiCoreRuntimePersistence(builder.Configuration, builder.Environment);
builder.Services.AddSingleton<IAdvisoryJitterSource, DefaultAdvisoryJitterSource>();
builder.Services.AddHostedService<AdvisoryTaskWorker>();

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: `advisory-ai-worker` now resolves shared AdvisoryAI explanation/policy/run/conversation/chat-settings runtime state through `AddAdvisoryAiCoreRuntimePersistence` instead of live in-memory authoritative stores. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,123 @@
// <copyright file="PostgresConversationService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Storage;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.Chat;
public sealed class PostgresConversationService : IConversationService
{
private readonly IConversationStore _store;
private readonly ConversationOptions _options;
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
private readonly ILogger<PostgresConversationService> _logger;
public PostgresConversationService(
IConversationStore store,
IOptions<ConversationOptions> options,
TimeProvider timeProvider,
IGuidGenerator guidGenerator,
ILogger<PostgresConversationService> logger)
{
_store = store;
_options = options.Value;
_timeProvider = timeProvider;
_guidGenerator = guidGenerator;
_logger = logger;
}
public async Task<Conversation> CreateAsync(
ConversationRequest request,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var conversation = new Conversation
{
ConversationId = GenerateConversationId(request),
TenantId = request.TenantId,
UserId = request.UserId,
CreatedAt = now,
UpdatedAt = now,
Context = request.InitialContext ?? new ConversationContext(),
Turns = ImmutableArray<ConversationTurn>.Empty,
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
};
var created = await _store.CreateAsync(conversation, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Created durable conversation {ConversationId}", created.ConversationId);
return created;
}
public Task<Conversation?> GetAsync(string conversationId, CancellationToken cancellationToken = default)
=> _store.GetByIdAsync(conversationId, cancellationToken);
public async Task<ConversationTurn> AddTurnAsync(
string conversationId,
TurnRequest request,
CancellationToken cancellationToken = default)
{
var conversation = await _store.GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
if (conversation is null)
{
throw new ConversationNotFoundException(conversationId);
}
var turn = new ConversationTurn
{
TurnId = $"{conversationId}-{conversation.Turns.Length + 1}",
Role = request.Role,
Content = request.Content,
Timestamp = _timeProvider.GetUtcNow(),
EvidenceLinks = request.EvidenceLinks ?? ImmutableArray<EvidenceLink>.Empty,
ProposedActions = request.ProposedActions ?? ImmutableArray<ProposedAction>.Empty,
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
};
if (conversation.Turns.Length >= _options.MaxTurnsPerConversation)
{
_logger.LogDebug(
"Durable conversation {ConversationId} exceeded max turn count {MaxTurns}; retention trimming remains append-only until turn-level pruning is added.",
conversationId,
_options.MaxTurnsPerConversation);
}
await _store.AddTurnAsync(conversationId, turn, cancellationToken).ConfigureAwait(false);
return turn;
}
public Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default)
=> _store.DeleteAsync(conversationId, cancellationToken);
public Task<IReadOnlyList<Conversation>> ListAsync(
string tenantId,
string? userId = null,
int? limit = null,
CancellationToken cancellationToken = default)
=> userId is null
? _store.GetByTenantAsync(tenantId, limit ?? 50, cancellationToken)
: _store.GetByUserAsync(tenantId, userId, limit ?? 50, cancellationToken);
public Task<Conversation?> UpdateContextAsync(
string conversationId,
ConversationContext context,
CancellationToken cancellationToken = default)
=> _store.UpdateContextAsync(conversationId, context, cancellationToken);
private string GenerateConversationId(ConversationRequest request)
{
var input = $"{request.TenantId}:{request.UserId}:{_timeProvider.GetUtcNow():O}:{_guidGenerator.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
var guidBytes = new byte[16];
Array.Copy(hash, guidBytes, 16);
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50);
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80);
return new Guid(guidBytes).ToString("N");
}
}

View File

@@ -0,0 +1,143 @@
// <copyright file="PostgresAdvisoryChatSettingsStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AdvisoryAI.Chat.Settings;
public sealed class PostgresAdvisoryChatSettingsStore
: RepositoryBase<AdvisoryAiDataSource>, IAdvisoryChatSettingsStore
{
private const string TenantScopeUserId = "";
public PostgresAdvisoryChatSettingsStore(
AdvisoryAiDataSource dataSource,
ILogger<PostgresAdvisoryChatSettingsStore> logger)
: base(dataSource, logger)
{
}
public Task<AdvisoryChatSettingsOverrides?> GetTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default)
=> GetOverridesAsync(tenantId, TenantScopeUserId, cancellationToken);
public Task<AdvisoryChatSettingsOverrides?> GetUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
=> GetOverridesAsync(tenantId, userId, cancellationToken);
public Task SetTenantOverridesAsync(
string tenantId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default)
=> SetOverridesAsync(tenantId, TenantScopeUserId, overrides, cancellationToken);
public Task SetUserOverridesAsync(
string tenantId,
string userId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken = default)
=> SetOverridesAsync(tenantId, userId, overrides, cancellationToken);
public Task<bool> ClearTenantOverridesAsync(
string tenantId,
CancellationToken cancellationToken = default)
=> ClearOverridesAsync(tenantId, TenantScopeUserId, cancellationToken);
public Task<bool> ClearUserOverridesAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
=> ClearOverridesAsync(tenantId, userId, cancellationToken);
private async Task<AdvisoryChatSettingsOverrides?> GetOverridesAsync(
string tenantId,
string scopeUserId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var json = await QuerySingleOrDefaultAsync(
tenantId,
"""
SELECT overrides_json::text
FROM advisoryai.runtime_chat_settings_overrides
WHERE tenant_id = @tenant_id
AND scope_user_id = @scope_user_id;
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "scope_user_id", scopeUserId);
},
reader => reader.GetString(0),
cancellationToken).ConfigureAwait(false);
return AdvisoryAiRuntimeJson.Deserialize<AdvisoryChatSettingsOverrides>(json);
}
private Task SetOverridesAsync(
string tenantId,
string scopeUserId,
AdvisoryChatSettingsOverrides overrides,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(overrides);
return ExecuteAsync(
tenantId,
"""
INSERT INTO advisoryai.runtime_chat_settings_overrides (
tenant_id,
scope_user_id,
updated_at,
overrides_json)
VALUES (
@tenant_id,
@scope_user_id,
@updated_at,
@overrides_json)
ON CONFLICT (tenant_id, scope_user_id) DO UPDATE
SET updated_at = EXCLUDED.updated_at,
overrides_json = EXCLUDED.overrides_json;
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "scope_user_id", scopeUserId);
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
AddJsonbParameter(command, "overrides_json", AdvisoryAiRuntimeJson.Serialize(overrides));
},
cancellationToken);
}
private async Task<bool> ClearOverridesAsync(
string tenantId,
string scopeUserId,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var rows = await ExecuteAsync(
tenantId,
"""
DELETE FROM advisoryai.runtime_chat_settings_overrides
WHERE tenant_id = @tenant_id
AND scope_user_id = @scope_user_id;
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "scope_user_id", scopeUserId);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
}

View File

@@ -0,0 +1,126 @@
// <copyright file="PostgresExplanationStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AdvisoryAI.Explanation;
public sealed class PostgresExplanationStore
: RepositoryBase<AdvisoryAiDataSource>, IExplanationStore, IExplanationRequestStore
{
public PostgresExplanationStore(
AdvisoryAiDataSource dataSource,
ILogger<PostgresExplanationStore> logger)
: base(dataSource, logger)
{
}
public Task StoreAsync(ExplanationResult result, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var generatedAt = DateTimeOffset.TryParse(result.GeneratedAt, out var parsed)
? parsed
: DateTimeOffset.UtcNow;
return ExecuteAsync(
string.Empty,
"""
INSERT INTO advisoryai.runtime_explanations (
explanation_id,
generated_at,
updated_at,
result_json)
VALUES (
@explanation_id,
@generated_at,
@updated_at,
@result_json)
ON CONFLICT (explanation_id) DO UPDATE
SET generated_at = EXCLUDED.generated_at,
updated_at = EXCLUDED.updated_at,
result_json = EXCLUDED.result_json;
""",
command =>
{
AddParameter(command, "explanation_id", result.ExplanationId);
AddParameter(command, "generated_at", generatedAt);
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
AddJsonbParameter(command, "result_json", AdvisoryAiRuntimeJson.Serialize(result));
},
cancellationToken);
}
public Task StoreRequestAsync(
string explanationId,
ExplanationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
ArgumentNullException.ThrowIfNull(request);
return ExecuteAsync(
string.Empty,
"""
INSERT INTO advisoryai.runtime_explanations (
explanation_id,
updated_at,
request_json)
VALUES (
@explanation_id,
@updated_at,
@request_json)
ON CONFLICT (explanation_id) DO UPDATE
SET updated_at = EXCLUDED.updated_at,
request_json = EXCLUDED.request_json;
""",
command =>
{
AddParameter(command, "explanation_id", explanationId);
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
AddJsonbParameter(command, "request_json", AdvisoryAiRuntimeJson.Serialize(request));
},
cancellationToken);
}
public async Task<ExplanationResult?> GetAsync(string explanationId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
var json = await QuerySingleOrDefaultAsync(
string.Empty,
"""
SELECT result_json::text
FROM advisoryai.runtime_explanations
WHERE explanation_id = @explanation_id
AND result_json IS NOT NULL;
""",
command => AddParameter(command, "explanation_id", explanationId),
reader => reader.GetString(0),
cancellationToken).ConfigureAwait(false);
return AdvisoryAiRuntimeJson.Deserialize<ExplanationResult>(json);
}
public async Task<ExplanationRequest?> GetRequestAsync(string explanationId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
var json = await QuerySingleOrDefaultAsync(
string.Empty,
"""
SELECT request_json::text
FROM advisoryai.runtime_explanations
WHERE explanation_id = @explanation_id
AND request_json IS NOT NULL;
""",
command => AddParameter(command, "explanation_id", explanationId),
reader => reader.GetString(0),
cancellationToken).ConfigureAwait(false);
return AdvisoryAiRuntimeJson.Deserialize<ExplanationRequest>(json);
}
}

View File

@@ -0,0 +1,65 @@
// <copyright file="PostgresPolicyIntentStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AdvisoryAI.PolicyStudio;
public sealed class PostgresPolicyIntentStore : RepositoryBase<AdvisoryAiDataSource>, IPolicyIntentStore
{
public PostgresPolicyIntentStore(
AdvisoryAiDataSource dataSource,
ILogger<PostgresPolicyIntentStore> logger)
: base(dataSource, logger)
{
}
public Task StoreAsync(PolicyIntent intent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(intent);
return ExecuteAsync(
string.Empty,
"""
INSERT INTO advisoryai.runtime_policy_intents (
intent_id,
updated_at,
intent_json)
VALUES (
@intent_id,
@updated_at,
@intent_json)
ON CONFLICT (intent_id) DO UPDATE
SET updated_at = EXCLUDED.updated_at,
intent_json = EXCLUDED.intent_json;
""",
command =>
{
AddParameter(command, "intent_id", intent.IntentId);
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
AddJsonbParameter(command, "intent_json", AdvisoryAiRuntimeJson.Serialize(intent));
},
cancellationToken);
}
public async Task<PolicyIntent?> GetAsync(string intentId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(intentId);
var json = await QuerySingleOrDefaultAsync(
string.Empty,
"""
SELECT intent_json::text
FROM advisoryai.runtime_policy_intents
WHERE intent_id = @intent_id;
""",
command => AddParameter(command, "intent_id", intentId),
reader => reader.GetString(0),
cancellationToken).ConfigureAwait(false);
return AdvisoryAiRuntimeJson.DeserializePolicyIntent(json);
}
}

View File

@@ -3,6 +3,7 @@
// </copyright>
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Runs;
@@ -163,6 +164,16 @@ public enum RunEventType
/// <summary>
/// Content of a run event (polymorphic).
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(TurnContent), "turn")]
[JsonDerivedType(typeof(ToolCallContent), "tool_call")]
[JsonDerivedType(typeof(ToolResultContent), "tool_result")]
[JsonDerivedType(typeof(ActionProposedContent), "action_proposed")]
[JsonDerivedType(typeof(ActionExecutedContent), "action_executed")]
[JsonDerivedType(typeof(ArtifactProducedContent), "artifact_produced")]
[JsonDerivedType(typeof(StatusChangedContent), "status_changed")]
[JsonDerivedType(typeof(OpsMemoryEnrichedContent), "opsmemory_enriched")]
[JsonDerivedType(typeof(ErrorContent), "error")]
public abstract record RunEventContent;
/// <summary>

View File

@@ -0,0 +1,365 @@
// <copyright file="PostgresRunStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Runs;
public sealed class PostgresRunStore : RepositoryBase<AdvisoryAiDataSource>, IRunStore
{
private static readonly ImmutableArray<RunStatus> ActiveStatuses =
[
RunStatus.Created,
RunStatus.Active,
RunStatus.PendingApproval
];
public PostgresRunStore(
AdvisoryAiDataSource dataSource,
ILogger<PostgresRunStore> logger)
: base(dataSource, logger)
{
}
public Task SaveAsync(Run run, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(run);
var approvers = run.Approval?.Approvers.IsDefaultOrEmpty == false
? run.Approval.Approvers.ToArray()
: null;
return ExecuteAsync(
run.TenantId,
"""
INSERT INTO advisoryai.runtime_runs (
tenant_id,
run_id,
initiated_by,
title,
status,
created_at,
updated_at,
completed_at,
focused_cve_id,
focused_component,
current_owner,
approval_required,
approvers,
run_json)
VALUES (
@tenant_id,
@run_id,
@initiated_by,
@title,
@status,
@created_at,
@updated_at,
@completed_at,
@focused_cve_id,
@focused_component,
@current_owner,
@approval_required,
@approvers,
@run_json)
ON CONFLICT (tenant_id, run_id) DO UPDATE
SET initiated_by = EXCLUDED.initiated_by,
title = EXCLUDED.title,
status = EXCLUDED.status,
created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at,
completed_at = EXCLUDED.completed_at,
focused_cve_id = EXCLUDED.focused_cve_id,
focused_component = EXCLUDED.focused_component,
current_owner = EXCLUDED.current_owner,
approval_required = EXCLUDED.approval_required,
approvers = EXCLUDED.approvers,
run_json = EXCLUDED.run_json;
""",
command =>
{
AddParameter(command, "tenant_id", run.TenantId);
AddParameter(command, "run_id", run.RunId);
AddParameter(command, "initiated_by", run.InitiatedBy);
AddParameter(command, "title", run.Title);
AddParameter(command, "status", (int)run.Status);
AddParameter(command, "created_at", run.CreatedAt);
AddParameter(command, "updated_at", run.UpdatedAt);
AddParameter(command, "completed_at", run.CompletedAt);
AddParameter(command, "focused_cve_id", run.Context.FocusedCveId);
AddParameter(command, "focused_component", run.Context.FocusedComponent);
AddParameter(command, "current_owner", run.Metadata.GetValueOrDefault("current_owner"));
AddParameter(command, "approval_required", run.Approval?.Required ?? false);
AddTextArrayParameter(command, "approvers", approvers);
AddJsonbParameter(command, "run_json", AdvisoryAiRuntimeJson.Serialize(run));
},
cancellationToken);
}
public async Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var json = await QuerySingleOrDefaultAsync(
tenantId,
"""
SELECT run_json::text
FROM advisoryai.runtime_runs
WHERE tenant_id = @tenant_id
AND run_id = @run_id;
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "run_id", runId);
},
reader => reader.GetString(0),
cancellationToken).ConfigureAwait(false);
return AdvisoryAiRuntimeJson.DeserializeRun(json);
}
public async Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
RunQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
await using var connection = await DataSource.OpenConnectionAsync(query.TenantId, "reader", cancellationToken)
.ConfigureAwait(false);
var whereClause = """
tenant_id = @tenant_id
AND (@statuses IS NULL OR status = ANY(@statuses))
AND (CAST(@initiated_by AS text) IS NULL OR initiated_by = @initiated_by)
AND (CAST(@cve_id AS text) IS NULL OR focused_cve_id = @cve_id)
AND (CAST(@component AS text) IS NULL OR focused_component = @component)
AND (CAST(@created_after AS timestamptz) IS NULL OR created_at >= @created_after)
AND (CAST(@created_before AS timestamptz) IS NULL OR created_at <= @created_before)
""";
await using var countCommand = CreateCommand(
$"SELECT COUNT(*) FROM advisoryai.runtime_runs WHERE {whereClause};",
connection);
ConfigureQueryCommand(countCommand, query);
var totalCount = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
await using var command = CreateCommand(
$"""
SELECT run_json::text
FROM advisoryai.runtime_runs
WHERE {whereClause}
ORDER BY created_at DESC
OFFSET @skip
LIMIT @take;
""",
connection);
ConfigureQueryCommand(command, query);
AddParameter(command, "skip", query.Skip);
AddParameter(command, "take", query.Take);
var runs = ImmutableArray.CreateBuilder<Run>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var run = AdvisoryAiRuntimeJson.DeserializeRun(reader.GetString(0));
if (run is not null)
{
runs.Add(run);
}
}
return (runs.ToImmutable(), totalCount);
}
public async Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var rows = await ExecuteAsync(
tenantId,
"""
DELETE FROM advisoryai.runtime_runs
WHERE tenant_id = @tenant_id
AND run_id = @run_id;
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "run_id", runId);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<ImmutableArray<Run>> GetByStatusAsync(
string tenantId,
ImmutableArray<RunStatus> statuses,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return await QueryRunsAsync(
tenantId,
"""
tenant_id = @tenant_id
AND status = ANY(@statuses)
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddStatusArrayParameter(command, "statuses", statuses);
AddParameter(command, "skip", skip);
AddParameter(command, "take", take);
},
"created_at DESC",
cancellationToken).ConfigureAwait(false);
}
public async Task<ImmutableArray<Run>> GetActiveForUserAsync(
string tenantId,
string userId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
return await QueryRunsAsync(
tenantId,
"""
tenant_id = @tenant_id
AND status = ANY(@statuses)
AND (initiated_by = @user_id OR current_owner = @user_id)
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddStatusArrayParameter(command, "statuses", ActiveStatuses);
AddParameter(command, "user_id", userId);
AddParameter(command, "skip", 0);
AddParameter(command, "take", 100);
},
"updated_at DESC",
cancellationToken).ConfigureAwait(false);
}
public async Task<ImmutableArray<Run>> GetPendingApprovalAsync(
string tenantId,
string? approverId = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return await QueryRunsAsync(
tenantId,
"""
tenant_id = @tenant_id
AND status = @pending_status
AND (@approver_id IS NULL OR @approver_id = ANY(COALESCE(approvers, ARRAY[]::text[])))
""",
command =>
{
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "pending_status", (int)RunStatus.PendingApproval);
AddParameter(command, "approver_id", approverId);
AddParameter(command, "skip", 0);
AddParameter(command, "take", 100);
},
"updated_at DESC",
cancellationToken).ConfigureAwait(false);
}
public async Task<bool> UpdateStatusAsync(
string tenantId,
string runId,
RunStatus newStatus,
CancellationToken cancellationToken = default)
{
var run = await GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
return false;
}
await SaveAsync(run with { Status = newStatus }, cancellationToken).ConfigureAwait(false);
return true;
}
private async Task<ImmutableArray<Run>> QueryRunsAsync(
string tenantId,
string whereClause,
Action<NpgsqlCommand> configureCommand,
string orderBy,
CancellationToken cancellationToken)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(
$"""
SELECT run_json::text
FROM advisoryai.runtime_runs
WHERE {whereClause}
ORDER BY {orderBy}
OFFSET @skip
LIMIT @take;
""",
connection);
configureCommand(command);
var runs = ImmutableArray.CreateBuilder<Run>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var run = AdvisoryAiRuntimeJson.DeserializeRun(reader.GetString(0));
if (run is not null)
{
runs.Add(run);
}
}
return runs.ToImmutable();
}
private static void ConfigureQueryCommand(NpgsqlCommand command, RunQuery query)
{
AddParameter(command, "tenant_id", query.TenantId);
AddParameter(command, "initiated_by", query.InitiatedBy);
AddParameter(command, "cve_id", query.CveId);
AddParameter(command, "component", query.Component);
AddParameter(command, "created_after", query.CreatedAfter);
AddParameter(command, "created_before", query.CreatedBefore);
AddStatusArrayParameter(command, "statuses", query.Statuses);
}
private static void AddStatusArrayParameter(
NpgsqlCommand command,
string name,
ImmutableArray<RunStatus>? statuses)
{
if (statuses is null || statuses.Value.IsDefaultOrEmpty)
{
command.Parameters.Add(new NpgsqlParameter(name, NpgsqlDbType.Array | NpgsqlDbType.Integer)
{
Value = DBNull.Value
});
return;
}
command.Parameters.Add(new NpgsqlParameter<int[]>(name, NpgsqlDbType.Array | NpgsqlDbType.Integer)
{
TypedValue = statuses.Value.Select(static status => (int)status).ToArray()
});
}
}

View File

@@ -143,6 +143,28 @@ public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IC
return entities.Select(MapConversation).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<Conversation>> GetByTenantAsync(
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenConnectionAsync(
tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
var entities = await dbContext.Conversations
.AsNoTracking()
.Where(c => c.TenantId == tenantId)
.OrderByDescending(c => c.UpdatedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapConversation).ToList();
}
/// <inheritdoc />
public async Task<Conversation> AddTurnAsync(
string conversationId,
@@ -188,6 +210,33 @@ public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IC
return (await GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false))!;
}
/// <inheritdoc />
public async Task<Conversation?> UpdateContextAsync(
string conversationId,
ConversationContext context,
CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenConnectionAsync(
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
var conversation = await dbContext.Conversations
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
.ConfigureAwait(false);
if (conversation is null)
{
return null;
}
conversation.Context = JsonSerializer.Serialize(context, JsonOptions);
conversation.UpdatedAt = _timeProvider.GetUtcNow().UtcDateTime;
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return await GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(
string conversationId,
@@ -355,9 +404,15 @@ public interface IConversationStore
/// <summary>Gets conversations for a user.</summary>
Task<IReadOnlyList<Conversation>> GetByUserAsync(string tenantId, string userId, int limit = 20, CancellationToken cancellationToken = default);
/// <summary>Gets conversations for a tenant.</summary>
Task<IReadOnlyList<Conversation>> GetByTenantAsync(string tenantId, int limit = 50, CancellationToken cancellationToken = default);
/// <summary>Adds a turn to a conversation.</summary>
Task<Conversation> AddTurnAsync(string conversationId, ConversationTurn turn, CancellationToken cancellationToken = default);
/// <summary>Updates the context for a conversation.</summary>
Task<Conversation?> UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default);
/// <summary>Deletes a conversation.</summary>
Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default);

View File

@@ -0,0 +1,59 @@
-- 009_ai_runtime_state.sql
-- Durable runtime state for AdvisoryAI consent and attestation records.
CREATE SCHEMA IF NOT EXISTS advisoryai;
CREATE TABLE IF NOT EXISTS advisoryai.ai_consents
(
tenant_id text NOT NULL,
user_id text NOT NULL,
consented boolean NOT NULL,
scope text NOT NULL,
consented_at timestamptz NULL,
expires_at timestamptz NULL,
session_level boolean NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ai_consents_tenant_updated
ON advisoryai.ai_consents (tenant_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_consents_expires
ON advisoryai.ai_consents (expires_at)
WHERE expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS advisoryai.ai_run_attestations
(
run_id text PRIMARY KEY,
tenant_id text NOT NULL,
started_at timestamptz NOT NULL,
completed_at timestamptz NOT NULL,
created_at timestamptz NOT NULL,
content_digest text NOT NULL,
attestation_json jsonb NOT NULL,
signed_envelope_json jsonb NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_run_attestations_tenant_created
ON advisoryai.ai_run_attestations (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_run_attestations_content_digest
ON advisoryai.ai_run_attestations (content_digest);
CREATE TABLE IF NOT EXISTS advisoryai.ai_claim_attestations
(
claim_id text PRIMARY KEY,
run_id text NOT NULL REFERENCES advisoryai.ai_run_attestations (run_id) ON DELETE CASCADE,
tenant_id text NOT NULL,
turn_id text NOT NULL,
content_digest text NOT NULL,
"timestamp" timestamptz NOT NULL,
claim_json jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_claim_attestations_run_timestamp
ON advisoryai.ai_claim_attestations (run_id, "timestamp");
CREATE INDEX IF NOT EXISTS idx_ai_claim_attestations_content_digest
ON advisoryai.ai_claim_attestations (content_digest);

View File

@@ -0,0 +1,109 @@
-- 010_advisory_ai_runtime_state_extensions.sql
-- Durable runtime state for explanations, policy intents, runs, and chat settings.
CREATE SCHEMA IF NOT EXISTS advisoryai;
CREATE TABLE IF NOT EXISTS advisoryai.runtime_explanations
(
explanation_id text PRIMARY KEY,
generated_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
result_json jsonb NULL,
request_json jsonb NULL
);
CREATE INDEX IF NOT EXISTS idx_runtime_explanations_generated_at
ON advisoryai.runtime_explanations (generated_at DESC);
CREATE TABLE IF NOT EXISTS advisoryai.runtime_policy_intents
(
intent_id text PRIMARY KEY,
updated_at timestamptz NOT NULL DEFAULT now(),
intent_json jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_runtime_policy_intents_updated_at
ON advisoryai.runtime_policy_intents (updated_at DESC);
CREATE TABLE IF NOT EXISTS advisoryai.runtime_runs
(
tenant_id text NOT NULL,
run_id text NOT NULL,
initiated_by text NOT NULL,
title text NOT NULL,
status integer NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
completed_at timestamptz NULL,
focused_cve_id text NULL,
focused_component text NULL,
current_owner text NULL,
approval_required boolean NOT NULL DEFAULT false,
approvers text[] NULL,
run_json jsonb NOT NULL,
PRIMARY KEY (tenant_id, run_id)
);
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_created
ON advisoryai.runtime_runs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_status_updated
ON advisoryai.runtime_runs (tenant_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_initiated
ON advisoryai.runtime_runs (tenant_id, initiated_by, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_cve
ON advisoryai.runtime_runs (tenant_id, focused_cve_id)
WHERE focused_cve_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_component
ON advisoryai.runtime_runs (tenant_id, focused_component)
WHERE focused_component IS NOT NULL;
CREATE TABLE IF NOT EXISTS advisoryai.runtime_chat_settings_overrides
(
tenant_id text NOT NULL,
scope_user_id text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now(),
overrides_json jsonb NOT NULL,
PRIMARY KEY (tenant_id, scope_user_id)
);
CREATE INDEX IF NOT EXISTS idx_runtime_chat_settings_updated_at
ON advisoryai.runtime_chat_settings_overrides (tenant_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS advisoryai.conversations
(
conversation_id text PRIMARY KEY,
tenant_id text NOT NULL,
user_id text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
context jsonb NULL,
metadata jsonb NULL
);
CREATE INDEX IF NOT EXISTS idx_advisoryai_conversations_tenant_updated
ON advisoryai.conversations (tenant_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_advisoryai_conversations_tenant_user
ON advisoryai.conversations (tenant_id, user_id);
CREATE TABLE IF NOT EXISTS advisoryai.turns
(
turn_id text PRIMARY KEY,
conversation_id text NOT NULL REFERENCES advisoryai.conversations (conversation_id) ON DELETE CASCADE,
role text NOT NULL,
content text NOT NULL,
"timestamp" timestamptz NOT NULL,
evidence_links jsonb NULL,
proposed_actions jsonb NULL,
metadata jsonb NULL
);
CREATE INDEX IF NOT EXISTS idx_advisoryai_turns_conversation
ON advisoryai.turns (conversation_id);
CREATE INDEX IF NOT EXISTS idx_advisoryai_turns_conversation_timestamp
ON advisoryai.turns (conversation_id, "timestamp");

View File

@@ -0,0 +1,133 @@
// <copyright file="AdvisoryAiRuntimeJson.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Runs;
namespace StellaOps.AdvisoryAI.Storage.Postgres;
internal static class AdvisoryAiRuntimeJson
{
private static readonly JsonSerializerOptions JsonOptions = CreateOptions();
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, JsonOptions);
public static T? Deserialize<T>(string? json)
=> string.IsNullOrWhiteSpace(json)
? default
: JsonSerializer.Deserialize<T>(json, JsonOptions);
public static Run? DeserializeRun(string? json)
=> Deserialize<Run>(json);
public static PolicyIntent? DeserializePolicyIntent(string? json)
{
var intent = Deserialize<PolicyIntent>(json);
return intent is null ? null : Normalize(intent);
}
private static JsonSerializerOptions CreateOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
private static PolicyIntent Normalize(PolicyIntent intent)
{
return intent with
{
Conditions = intent.Conditions
.Select(condition => condition with { Value = NormalizeValue(condition.Value) ?? string.Empty })
.ToArray(),
Actions = intent.Actions
.Select(action => action with
{
Parameters = NormalizeDictionary(action.Parameters)
})
.ToArray(),
Alternatives = intent.Alternatives?.Select(Normalize).ToArray()
};
}
private static IReadOnlyDictionary<string, object> NormalizeDictionary(IReadOnlyDictionary<string, object> parameters)
{
var normalized = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var entry in parameters)
{
normalized[entry.Key] = NormalizeValue(entry.Value) ?? string.Empty;
}
return normalized;
}
private static object? NormalizeValue(object? value)
{
return value switch
{
null => null,
JsonElement element => NormalizeJsonElement(element),
JsonDocument document => NormalizeJsonElement(document.RootElement),
IDictionary dictionary => NormalizeDictionary(dictionary),
IEnumerable enumerable when value is not string => NormalizeList(enumerable),
_ => value
};
}
private static object? NormalizeJsonElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var longValue)
? longValue
: element.TryGetDecimal(out var decimalValue)
? decimalValue
: element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => element.EnumerateArray().Select(NormalizeJsonElement).ToList(),
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(property => property.Name, property => NormalizeJsonElement(property.Value) ?? string.Empty, StringComparer.Ordinal),
_ => element.GetRawText()
};
}
private static object NormalizeDictionary(IDictionary dictionary)
{
var normalized = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (DictionaryEntry entry in dictionary)
{
var key = entry.Key?.ToString();
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
normalized[key] = NormalizeValue(entry.Value) ?? string.Empty;
}
return normalized;
}
private static object NormalizeList(IEnumerable enumerable)
{
var items = new List<object?>();
foreach (var item in enumerable)
{
items.Add(NormalizeValue(item));
}
return items;
}
}

View File

@@ -5,6 +5,8 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| Task ID | Status | Notes |
| --- | --- | --- |
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: explanation replay, policy intent, run state, conversation state, and chat settings are now authoritative PostgreSQL-backed runtime stores under `advisoryai`, with embedded migration `010_advisory_ai_runtime_state_extensions.sql` and restart-survival proof in `AdvisoryAiDurableRuntimeTests` (`6/6`). |
| OPSREAL-003 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: advisory AI attestation flow is now store-backed end-to-end, and migration `009_ai_runtime_state.sql` adds durable consent/run/claim attestation tables under `advisoryai`. Focused evidence: `StellaOps.AdvisoryAI.Attestation.Tests` `58/58`, `AdvisoryAiDurableRuntimeTests` `2/2`. |
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named advisory chat audit PostgreSQL sessions and aligned the first repo-wide transport hardening wave. |
| SPRINT_20260405_010-AIAI-PG | DONE | `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md`: AdvisoryAI PostgreSQL application-name/pooling follow-up and Gitea spike capture. |
| SPRINT_20260223_100-USRCH-POL-005 | DONE | Security hardening closure: tenant-scoped adapter identities, backend+frontend snippet sanitization, and threat-model docs. Evidence: `UnifiedSearchLiveAdapterIntegrationTests` (11/11), `UnifiedSearchSprintIntegrationTests` (109/109), targeted snippet test (1/1). |

View File

@@ -555,7 +555,17 @@ internal sealed class InMemoryConversationStore : IConversationStore
{
var result = _conversations.Values
.Where(c => c.TenantId == tenantId && c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.OrderByDescending(c => c.UpdatedAt)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<Conversation>>(result);
}
public Task<IReadOnlyList<Conversation>> GetByTenantAsync(string tenantId, int limit = 50, CancellationToken cancellationToken = default)
{
var result = _conversations.Values
.Where(c => c.TenantId == tenantId)
.OrderByDescending(c => c.UpdatedAt)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<Conversation>>(result);
@@ -574,6 +584,18 @@ internal sealed class InMemoryConversationStore : IConversationStore
return Task.FromResult(updated);
}
public Task<Conversation?> UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default)
{
if (!_conversations.TryGetValue(conversationId, out var conversation))
{
return Task.FromResult<Conversation?>(null);
}
var updated = conversation with { Context = context, UpdatedAt = DateTimeOffset.UtcNow };
_conversations[conversationId] = updated;
return Task.FromResult<Conversation?>(updated);
}
public Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default)
{
return Task.FromResult(_conversations.Remove(conversationId));

View File

@@ -0,0 +1,541 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.Explanation;
using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Runs;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using StellaOps.AdvisoryAI.WebService.Services;
using StellaOps.TestKit;
using StellaOps.TestKit.Fixtures;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Integration;
public sealed class AdvisoryAiDurableRuntimeTests : IClassFixture<PostgresFixture>
{
private readonly PostgresFixture _postgres;
public AdvisoryAiDurableRuntimeTests(PostgresFixture postgres)
{
_postgres = postgres;
_postgres.IsolationMode = PostgresIsolationMode.DatabasePerTest;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Consent_PersistsAcrossHostRestart_WithDurableStore()
{
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_consent_runtime");
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
using (var factory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory-ai:view");
var grantResponse = await client.PostAsJsonAsync(
"/v1/advisory-ai/consent",
new AiConsentGrantRequest
{
Scope = "all",
SessionLevel = false,
DataShareAcknowledged = true,
},
TestContext.Current.CancellationToken);
grantResponse.StatusCode.Should().Be(HttpStatusCode.OK);
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IAiConsentStore>().Should().BeOfType<PostgresAiConsentStore>();
scope.ServiceProvider.GetRequiredService<IAiAttestationStore>().Should().BeOfType<PostgresAiAttestationStore>();
}
using (var restartedFactory = CreateDurableFactory(connectionString))
{
using var restartedClient = CreateAuthorizedClient(restartedFactory, "advisory-ai:view");
var consentStatus = await restartedClient.GetFromJsonAsync<AiConsentStatusResponse>(
"/v1/advisory-ai/consent",
TestContext.Current.CancellationToken);
consentStatus.Should().NotBeNull();
consentStatus!.Consented.Should().BeTrue();
consentStatus.Scope.Should().Be("all");
consentStatus.SessionLevel.Should().BeFalse();
consentStatus.ConsentedBy.Should().Be("test-user");
}
await AssertRuntimeTablesExistAsync(connectionString);
NpgsqlConnection.ClearAllPools();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Attestations_PersistAcrossHostRestart_AndRoundTripThroughEndpoints()
{
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_attestation_runtime");
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
const string tenantId = "tenant-advisory-proof";
const string runId = "run-durable-001";
const string claimId = "claim-durable-001";
using (var factory = CreateDurableFactory(connectionString))
{
using var scope = factory.Services.CreateScope();
var attestationService = scope.ServiceProvider.GetRequiredService<IAiAttestationService>();
scope.ServiceProvider.GetRequiredService<IAiAttestationStore>().Should().BeOfType<PostgresAiAttestationStore>();
var runAttestation = new AiRunAttestation
{
RunId = runId,
TenantId = tenantId,
UserId = "test-user",
ConversationId = "conversation-durable-001",
StartedAt = new DateTimeOffset(2026, 04, 15, 10, 00, 00, TimeSpan.Zero),
CompletedAt = new DateTimeOffset(2026, 04, 15, 10, 05, 00, TimeSpan.Zero),
Model = new AiModelInfo
{
Provider = "openai",
ModelId = "gpt-5.4-mini",
},
OverallGroundingScore = 0.92,
TotalTokens = 512,
};
var claimAttestation = new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = "turn-durable-001",
TenantId = tenantId,
ClaimText = "The advisory remediation remains gated by policy evidence.",
ClaimDigest = "sha256:claim-durable-001",
GroundingScore = 0.88,
Timestamp = new DateTimeOffset(2026, 04, 15, 10, 04, 00, TimeSpan.Zero),
ContentDigest = "sha256:content-durable-001",
};
var createdRun = await attestationService.CreateRunAttestationAsync(
runAttestation,
sign: true,
TestContext.Current.CancellationToken);
createdRun.Signed.Should().BeTrue();
await attestationService.CreateClaimAttestationAsync(
claimAttestation,
sign: false,
TestContext.Current.CancellationToken);
}
using (var restartedFactory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:view");
var runResponse = await client.GetFromJsonAsync<RunAttestationResponse>(
$"/v1/advisory-ai/runs/{runId}/attestation",
TestContext.Current.CancellationToken);
runResponse.Should().NotBeNull();
runResponse!.RunId.Should().Be(runId);
runResponse.Attestation.TenantId.Should().Be(tenantId);
runResponse.Envelope.Should().NotBeNull();
var claimsResponse = await client.GetFromJsonAsync<ClaimsListResponse>(
$"/v1/advisory-ai/runs/{runId}/claims",
TestContext.Current.CancellationToken);
claimsResponse.Should().NotBeNull();
claimsResponse!.Count.Should().Be(1);
claimsResponse.Claims.Should().ContainSingle(claim => claim.ClaimId == claimId);
var verifyResponse = await client.PostAsJsonAsync(
"/v1/advisory-ai/attestations/verify",
new VerifyAttestationRequest { RunId = runId },
TestContext.Current.CancellationToken);
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var verification = await verifyResponse.Content.ReadFromJsonAsync<AttestationVerificationResponse>(
cancellationToken: TestContext.Current.CancellationToken);
verification.Should().NotBeNull();
verification!.IsValid.Should().BeTrue();
verification.DigestValid.Should().BeTrue();
verification.SignatureValid.Should().BeTrue();
verification.SigningKeyId.Should().NotBeNullOrWhiteSpace();
}
NpgsqlConnection.ClearAllPools();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ExplanationReplay_PersistsAcrossHostRestart_WithDurableStore()
{
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_explanation_runtime");
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
ExplainResponse created;
using (var factory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory:explain advisory-ai:view");
var response = await client.PostAsJsonAsync(
"/v1/advisory-ai/explain",
new ExplainRequest
{
FindingId = "finding-001",
ArtifactDigest = "sha256:artifact-001",
Scope = "tenant",
ScopeId = "tenant-advisory-proof",
VulnerabilityId = "CVE-2026-0001",
ComponentPurl = "pkg:npm/example@1.2.3",
ExplanationType = "full",
PlainLanguage = true,
},
TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
created = (await response.Content.ReadFromJsonAsync<ExplainResponse>(
cancellationToken: TestContext.Current.CancellationToken))!;
created.ExplanationId.Should().NotBeNullOrWhiteSpace();
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IExplanationStore>().Should().BeOfType<PostgresExplanationStore>();
}
using (var restartedFactory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory:explain advisory-ai:view");
var replay = await client.GetFromJsonAsync<ExplainResponse>(
$"/v1/advisory-ai/explain/{created.ExplanationId}/replay",
TestContext.Current.CancellationToken);
replay.Should().NotBeNull();
replay!.ExplanationId.Should().Be(created.ExplanationId);
replay.Content.Should().Be(created.Content);
replay.OutputHash.Should().Be(created.OutputHash);
}
await AssertCoreRuntimeTablesExistAsync(connectionString);
NpgsqlConnection.ClearAllPools();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task PolicyIntent_PersistsAcrossHostRestart_AndGenerateEndpointUsesStoredIntent()
{
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_policy_runtime");
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
PolicyParseApiResponse parsed;
using (var factory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory:run advisory-ai:view");
var response = await client.PostAsJsonAsync(
"/v1/advisory-ai/policy/studio/parse",
new PolicyParseApiRequest
{
Input = "Block critical reachable vulnerabilities for production workloads.",
DefaultScope = "service",
},
TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
parsed = (await response.Content.ReadFromJsonAsync<PolicyParseApiResponse>(
cancellationToken: TestContext.Current.CancellationToken))!;
parsed.Intent.IntentId.Should().NotBeNullOrWhiteSpace();
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IPolicyIntentStore>().Should().BeOfType<PostgresPolicyIntentStore>();
}
using (var restartedFactory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory:run advisory-ai:view");
var response = await client.PostAsJsonAsync(
"/v1/advisory-ai/policy/studio/generate",
new PolicyGenerateApiRequest { IntentId = parsed.Intent.IntentId },
TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var generated = await response.Content.ReadFromJsonAsync<RuleGenerationApiResponse>(
cancellationToken: TestContext.Current.CancellationToken);
generated.Should().NotBeNull();
generated!.IntentId.Should().Be(parsed.Intent.IntentId);
generated.Success.Should().BeTrue();
generated.Rules.Should().NotBeEmpty();
}
NpgsqlConnection.ClearAllPools();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Runs_PersistAcrossHostRestart_AndRemainQueryable()
{
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_run_runtime");
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
StellaOps.AdvisoryAI.WebService.Endpoints.RunDto createdRun;
using (var factory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory-ai:view");
var createResponse = await client.PostAsJsonAsync(
"/v1/advisory-ai/runs",
new StellaOps.AdvisoryAI.WebService.Endpoints.CreateRunRequestDto
{
Title = "Investigate CVE-2026-0002",
Objective = "Confirm runtime exposure",
Context = new StellaOps.AdvisoryAI.WebService.Endpoints.RunContextDto
{
FocusedCveId = "CVE-2026-0002",
FocusedComponent = "pkg:npm/runtime@2.0.0",
}
},
TestContext.Current.CancellationToken);
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
createdRun = (await createResponse.Content.ReadFromJsonAsync<StellaOps.AdvisoryAI.WebService.Endpoints.RunDto>(
cancellationToken: TestContext.Current.CancellationToken))!;
var approvalResponse = await client.PostAsJsonAsync(
$"/v1/advisory-ai/runs/{createdRun.RunId}/approval/request",
new StellaOps.AdvisoryAI.WebService.Endpoints.RequestApprovalDto
{
Approvers = ["approver-1"],
Reason = "Needs security approval",
},
TestContext.Current.CancellationToken);
approvalResponse.StatusCode.Should().Be(HttpStatusCode.OK);
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IRunStore>().Should().BeOfType<PostgresRunStore>();
}
using (var restartedFactory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory-ai:view");
var run = await client.GetFromJsonAsync<StellaOps.AdvisoryAI.WebService.Endpoints.RunDto>(
$"/v1/advisory-ai/runs/{createdRun.RunId}",
TestContext.Current.CancellationToken);
run.Should().NotBeNull();
run!.Status.Should().Be(RunStatus.PendingApproval.ToString());
run.Context!.FocusedCveId.Should().Be("CVE-2026-0002");
var pending = await client.GetFromJsonAsync<List<StellaOps.AdvisoryAI.WebService.Endpoints.RunDto>>(
"/v1/advisory-ai/runs/pending-approval",
TestContext.Current.CancellationToken);
pending.Should().ContainSingle(item => item.RunId == createdRun.RunId);
var query = await client.GetFromJsonAsync<StellaOps.AdvisoryAI.WebService.Endpoints.RunQueryResultDto>(
"/v1/advisory-ai/runs?cveId=CVE-2026-0002&take=10",
TestContext.Current.CancellationToken);
query.Should().NotBeNull();
query!.Runs.Should().Contain(item => item.RunId == createdRun.RunId);
}
NpgsqlConnection.ClearAllPools();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Conversations_AndChatSettings_PersistAcrossHostRestart()
{
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_chat_runtime");
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
ConversationResponse createdConversation;
using (var factory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory-ai:view advisory:chat chat:user");
var settingsResponse = await client.PutAsJsonAsync(
"/api/v1/chat/settings?scope=user",
new ChatSettingsUpdateRequest
{
Quotas = new ChatQuotaSettingsUpdateRequest
{
RequestsPerMinute = 7,
ToolCallsPerDay = 11,
},
Tools = new ChatToolAccessUpdateRequest
{
AllowAll = false,
AllowedTools = ["policy.read", "sbom.read"],
}
},
TestContext.Current.CancellationToken);
settingsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var createConversationResponse = await client.PostAsJsonAsync(
"/v1/advisory-ai/conversations",
new CreateConversationRequest
{
TenantId = "tenant-advisory-proof",
Context = new ConversationContextRequest
{
CurrentCveId = "CVE-2026-0003",
}
},
TestContext.Current.CancellationToken);
createConversationResponse.StatusCode.Should().Be(HttpStatusCode.Created);
createdConversation = (await createConversationResponse.Content.ReadFromJsonAsync<ConversationResponse>(
cancellationToken: TestContext.Current.CancellationToken))!;
var addTurnResponse = await client.PostAsJsonAsync(
$"/v1/advisory-ai/conversations/{createdConversation.ConversationId}/turns",
new AddTurnRequest
{
Content = "Explain the blast radius for this finding.",
Stream = false,
},
TestContext.Current.CancellationToken);
addTurnResponse.StatusCode.Should().Be(HttpStatusCode.OK);
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IConversationService>().Should().BeOfType<PostgresConversationService>();
scope.ServiceProvider.GetRequiredService<IAdvisoryChatSettingsStore>().Should().BeOfType<PostgresAdvisoryChatSettingsStore>();
}
using (var restartedFactory = CreateDurableFactory(connectionString))
{
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory-ai:view advisory:chat chat:user");
var settings = await client.GetFromJsonAsync<ChatSettingsResponse>(
"/api/v1/chat/settings",
TestContext.Current.CancellationToken);
settings.Should().NotBeNull();
settings!.Quotas.RequestsPerMinute.Should().Be(7);
settings.Quotas.ToolCallsPerDay.Should().Be(11);
settings.Tools.AllowAll.Should().BeFalse();
settings.Tools.AllowedTools.Should().Contain(["policy.read", "sbom.read"]);
var conversation = await client.GetFromJsonAsync<ConversationResponse>(
$"/v1/advisory-ai/conversations/{createdConversation.ConversationId}",
TestContext.Current.CancellationToken);
conversation.Should().NotBeNull();
conversation!.Turns.Should().HaveCountGreaterThanOrEqualTo(2);
var conversations = await client.GetFromJsonAsync<ConversationListResponse>(
"/v1/advisory-ai/conversations?tenantId=tenant-advisory-proof&limit=10",
TestContext.Current.CancellationToken);
conversations.Should().NotBeNull();
conversations!.Conversations.Should().Contain(item => item.ConversationId == createdConversation.ConversationId);
}
NpgsqlConnection.ClearAllPools();
}
private static async Task AssertRuntimeTablesExistAsync(string connectionString)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(TestContext.Current.CancellationToken);
await using var command = new NpgsqlCommand(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'advisoryai'
AND table_name IN ('ai_consents', 'ai_run_attestations', 'ai_claim_attestations')
ORDER BY table_name;
""",
connection);
var tableNames = new List<string>();
await using var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
while (await reader.ReadAsync(TestContext.Current.CancellationToken))
{
tableNames.Add(reader.GetString(0));
}
tableNames.Should().Equal("ai_claim_attestations", "ai_consents", "ai_run_attestations");
}
private static async Task AssertCoreRuntimeTablesExistAsync(string connectionString)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(TestContext.Current.CancellationToken);
await using var command = new NpgsqlCommand(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'advisoryai'
AND table_name IN (
'runtime_explanations',
'runtime_policy_intents',
'runtime_runs',
'runtime_chat_settings_overrides')
ORDER BY table_name;
""",
connection);
var tableNames = new List<string>();
await using var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
while (await reader.ReadAsync(TestContext.Current.CancellationToken))
{
tableNames.Add(reader.GetString(0));
}
tableNames.Should().Equal(
"runtime_chat_settings_overrides",
"runtime_explanations",
"runtime_policy_intents",
"runtime_runs");
}
private static HttpClient CreateAuthorizedClient(WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> factory, string scopes)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "test-user");
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", scopes);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-advisory-proof");
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-advisory-proof");
client.DefaultRequestHeaders.Add("X-User-Id", "test-user");
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
return client;
}
private static string CreateNonPooledConnectionString(string connectionString)
{
var builder = new NpgsqlConnectionStringBuilder(connectionString)
{
Pooling = false,
};
return builder.ConnectionString;
}
private static WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> CreateDurableFactory(string connectionString)
{
return new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>().WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
builder.UseSetting("AdvisoryAI:Storage:ConnectionString", connectionString);
});
}
}

View File

@@ -6,6 +6,9 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| AIAIRUNTIME-022 | DONE | `docs/implplan/SPRINT_20260417_022_AdvisoryAI_truthful_testing_only_runtime_fallback.md`: added `AdvisoryAiRuntimeStartupContractTests` to prove `Testing`-only fallback (`2/2`) and reran `AdvisoryAiDurableRuntimeTests` directly through the xUnit v3 runner for durable non-regression (`6/6`). |
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: `AdvisoryAiDurableRuntimeTests` now prove explanation replay, policy-intent lookup, run state, conversation state, chat settings, consent, and attestation survive host restart against the real AdvisoryAI web runtime (`6/6`). |
| OPSREAL-003 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: added `AdvisoryAiDurableRuntimeTests` for consent and attestation restart-survival against live `WebApplicationFactory` + PostgreSQL (`2/2`). A separate whole-project MTP run was attempted but not used as evidence because it stalled without emitting terminal results. |
| SPRINT_20260222_051-AKS-TESTS | DONE | Revalidated AKS tests with xUnit v3 `--filter-class`: `KnowledgeSearchEndpointsIntegrationTests` (3/3) and `*KnowledgeSearch*` suite slice (6/6) on 2026-02-22. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |