Search/AdvisoryAI and DAL conversion to EF finishes up. Preparation for microservices consolidation.

This commit is contained in:
master
2026-02-25 18:19:22 +02:00
parent 4db038123b
commit 63c70a6d37
447 changed files with 52257 additions and 2636 deletions

View File

@@ -34,6 +34,11 @@
### Search sprint test infrastructure (G1G10)
**Infrastructure setup guide**: `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md` — covers what each test tier needs and exact Docker/config steps.
Full feature documentation: `docs/modules/advisory-ai/knowledge-search.md` → "Search improvement sprints (G1G10) — testing infrastructure guide".
Unified-search architecture and operations docs:
- `docs/modules/advisory-ai/unified-search-architecture.md`
- `docs/operations/unified-search-operations.md`
- `docs/modules/advisory-ai/unified-search-ranking-benchmark.md`
- `docs/modules/advisory-ai/unified-search-release-readiness.md`
**Quick-start (no Docker required):**
```bash
@@ -66,6 +71,9 @@ dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.Advisory
**Key test files:**
- `Integration/UnifiedSearchSprintIntegrationTests.cs` — 87 integration tests covering all 10 sprints
- `UnifiedSearch/UnifiedSearchQualityBenchmarkFastSubsetTests.cs` — 50-query PR benchmark gate
- `UnifiedSearch/UnifiedSearchQualityBenchmarkTests.cs` — full quality benchmark + deterministic tuning grid search
- `UnifiedSearch/UnifiedSearchPerformanceEnvelopeTests.cs` — latency/capacity envelope assertions
- `KnowledgeSearch/FtsRecallBenchmarkTests.cs` + `FtsRecallBenchmarkStore.cs` — FTS recall benchmark
- `KnowledgeSearch/SemanticRecallBenchmarkTests.cs` + `SemanticRecallBenchmarkStore.cs` — Semantic recall benchmark
- `TestData/fts-recall-benchmark.json` — 34-query FTS fixture

View File

@@ -16,7 +16,8 @@ public static class SearchAnalyticsEndpoints
{
"query",
"click",
"zero_result"
"zero_result",
"synthesis"
};
public static RouteGroupBuilder MapSearchAnalyticsEndpoints(this IEndpointRouteBuilder builder)
@@ -29,10 +30,10 @@ public static class SearchAnalyticsEndpoints
group.MapPost("/analytics", RecordAnalyticsAsync)
.WithName("SearchAnalyticsRecord")
.WithSummary("Records batch search analytics events (query, click, zero_result).")
.WithSummary("Records batch search analytics events (query, click, zero_result, synthesis).")
.WithDescription(
"Accepts a batch of search analytics events for tracking query frequency, click-through rates, " +
"and zero-result queries. Events are tenant-scoped and user ID is optional for privacy. " +
"zero-result queries, and synthesis usage. Queries and user identifiers are pseudonymized before persistence. " +
"Fire-and-forget from the client; failures do not affect search functionality.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces(StatusCodes.Status204NoContent)

View File

@@ -29,7 +29,7 @@ public static class SearchFeedbackEndpoints
.WithSummary("Submits user feedback (helpful/not_helpful) for a search result or synthesis.")
.WithDescription(
"Records a thumbs-up or thumbs-down signal for a specific search result, " +
"identified by entity key and domain. Used to improve search quality over time. " +
"identified by entity key and domain. Query/user dimensions are pseudonymized for analytics privacy. " +
"Fire-and-forget from the UI perspective.")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.Produces(StatusCodes.Status201Created)
@@ -199,6 +199,34 @@ public static class SearchFeedbackEndpoints
AvgResultCount = metrics.AvgResultCount,
FeedbackScore = metrics.FeedbackScore,
Period = metrics.Period,
LowQualityResults = metrics.LowQualityResults
.Select(row => new SearchLowQualityResultDto
{
EntityKey = row.EntityKey,
Domain = row.Domain,
NegativeFeedbackCount = row.NegativeFeedbackCount,
TotalFeedback = row.TotalFeedback,
NegativeRate = row.NegativeRate,
})
.ToArray(),
TopQueries = metrics.TopQueries
.Select(row => new SearchTopQueryDto
{
Query = row.Query,
TotalSearches = row.TotalSearches,
AvgResultCount = row.AvgResultCount,
FeedbackScore = row.FeedbackScore,
})
.ToArray(),
Trend = metrics.Trend
.Select(point => new SearchQualityTrendPointDto
{
Day = point.Day.ToString("yyyy-MM-dd"),
TotalSearches = point.TotalSearches,
ZeroResultRate = point.ZeroResultRate,
FeedbackScore = point.FeedbackScore,
})
.ToArray(),
});
}
@@ -281,4 +309,32 @@ public sealed record SearchQualityMetricsDto
public double AvgResultCount { get; init; }
public double FeedbackScore { get; init; }
public string Period { get; init; } = "7d";
public IReadOnlyList<SearchLowQualityResultDto> LowQualityResults { get; init; } = [];
public IReadOnlyList<SearchTopQueryDto> TopQueries { get; init; } = [];
public IReadOnlyList<SearchQualityTrendPointDto> Trend { get; init; } = [];
}
public sealed record SearchLowQualityResultDto
{
public string EntityKey { get; init; } = string.Empty;
public string Domain { get; init; } = string.Empty;
public int NegativeFeedbackCount { get; init; }
public int TotalFeedback { get; init; }
public double NegativeRate { get; init; }
}
public sealed record SearchTopQueryDto
{
public string Query { get; init; } = string.Empty;
public int TotalSearches { get; init; }
public double AvgResultCount { get; init; }
public double FeedbackScore { get; init; }
}
public sealed record SearchQualityTrendPointDto
{
public string Day { get; init; } = string.Empty;
public int TotalSearches { get; init; }
public double ZeroResultRate { get; init; }
public double FeedbackScore { get; init; }
}

View File

@@ -2,9 +2,11 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Linq;
using System.Text.Json;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -17,7 +19,11 @@ public static class UnifiedSearchEndpoints
"findings",
"vex",
"policy",
"platform"
"platform",
"graph",
"timeline",
"scanner",
"opsmemory"
};
private static readonly HashSet<string> AllowedEntityTypes = new(StringComparer.Ordinal)
@@ -28,7 +34,13 @@ public static class UnifiedSearchEndpoints
"finding",
"vex_statement",
"policy_rule",
"platform_entity"
"platform_entity",
"package",
"image",
"registry",
"event",
"scan",
"graph_node"
};
public static RouteGroupBuilder MapUnifiedSearchEndpoints(this IEndpointRouteBuilder builder)
@@ -51,6 +63,17 @@ public static class UnifiedSearchEndpoints
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/synthesize", SynthesizeAsync)
.WithName("UnifiedSearchSynthesize")
.WithSummary("Streams deterministic-first search synthesis as SSE.")
.WithDescription(
"Produces deterministic synthesis first, then optional LLM synthesis chunks, grounding score, and actions. " +
"Requires search synthesis scope and tenant context.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces(StatusCodes.Status200OK, contentType: "text/event-stream")
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/index/rebuild", RebuildIndexAsync)
.WithName("UnifiedSearchRebuild")
.WithSummary("Rebuilds unified search index from configured ingestion sources.")
@@ -90,12 +113,14 @@ public static class UnifiedSearchEndpoints
try
{
var userScopes = ResolveUserScopes(httpContext);
var userId = ResolveUserId(httpContext);
var domainRequest = new UnifiedSearchRequest(
request.Q.Trim(),
request.K,
NormalizeFilter(request.Filters, tenant, userScopes),
NormalizeFilter(request.Filters, tenant, userScopes, userId),
request.IncludeSynthesis,
request.IncludeDebug);
request.IncludeDebug,
NormalizeAmbient(request.Ambient));
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
return Results.Ok(MapResponse(response));
@@ -106,6 +131,158 @@ public static class UnifiedSearchEndpoints
}
}
private static async Task SynthesizeAsync(
HttpContext httpContext,
UnifiedSearchSynthesizeApiRequest request,
SearchSynthesisService synthesisService,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Q))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new { error = _t("advisoryai.validation.q_required") }, cancellationToken);
return;
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new { error = _t("advisoryai.validation.tenant_required") }, cancellationToken);
return;
}
if (!HasSynthesisScope(httpContext))
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsJsonAsync(new { error = "Missing required scope: search:synthesize" }, cancellationToken);
return;
}
var cards = MapSynthesisCards(request.TopCards);
if (cards.Count == 0)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new { error = "topCards is required" }, cancellationToken);
return;
}
var userId = ResolveUserId(httpContext) ?? "anonymous";
var domainRequest = new SearchSynthesisRequest(
request.Q.Trim(),
cards,
request.Plan,
request.Preferences is null
? null
: new SearchSynthesisPreferences
{
Depth = request.Preferences.Depth,
MaxTokens = request.Preferences.MaxTokens,
IncludeActions = request.Preferences.IncludeActions,
Locale = request.Preferences.Locale
});
httpContext.Response.StatusCode = StatusCodes.Status200OK;
httpContext.Response.ContentType = "text/event-stream";
httpContext.Response.Headers.CacheControl = "no-cache";
httpContext.Response.Headers.Connection = "keep-alive";
await httpContext.Response.Body.FlushAsync(cancellationToken);
var started = DateTimeOffset.UtcNow;
try
{
var result = await synthesisService.ExecuteAsync(
tenant,
userId,
domainRequest,
cancellationToken).ConfigureAwait(false);
await WriteSseEventAsync(httpContext, "synthesis_start", new
{
tier = "deterministic",
summary = result.DeterministicSummary
}, cancellationToken).ConfigureAwait(false);
if (result.QuotaExceeded)
{
await WriteSseEventAsync(httpContext, "llm_status", new { status = "quota_exceeded" }, cancellationToken).ConfigureAwait(false);
}
else if (result.LlmUnavailable || string.IsNullOrWhiteSpace(result.LlmSummary))
{
await WriteSseEventAsync(httpContext, "llm_status", new { status = "unavailable" }, cancellationToken).ConfigureAwait(false);
}
else
{
await WriteSseEventAsync(httpContext, "llm_status", new { status = "starting" }, cancellationToken).ConfigureAwait(false);
await WriteSseEventAsync(httpContext, "llm_status", new { status = "streaming" }, cancellationToken).ConfigureAwait(false);
foreach (var chunk in Chunk(result.LlmSummary, 240))
{
await WriteSseEventAsync(httpContext, "llm_chunk", new
{
content = chunk,
isComplete = false
}, cancellationToken).ConfigureAwait(false);
}
await WriteSseEventAsync(httpContext, "llm_chunk", new
{
content = string.Empty,
isComplete = true
}, cancellationToken).ConfigureAwait(false);
await WriteSseEventAsync(httpContext, "llm_status", new { status = "validating" }, cancellationToken).ConfigureAwait(false);
await WriteSseEventAsync(httpContext, "grounding", new
{
score = result.GroundingScore,
citations = 0,
ungrounded = 0,
issues = Array.Empty<string>()
}, cancellationToken).ConfigureAwait(false);
await WriteSseEventAsync(httpContext, "llm_status", new { status = "complete" }, cancellationToken).ConfigureAwait(false);
}
if (result.Actions.Count > 0)
{
await WriteSseEventAsync(httpContext, "actions", new
{
actions = result.Actions.Select(static action => new
{
label = action.Label,
route = action.Route,
sourceEntityKey = action.SourceEntityKey
})
}, cancellationToken).ConfigureAwait(false);
}
var durationMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds;
await WriteSseEventAsync(httpContext, "synthesis_end", new
{
totalTokens = result.TotalTokens,
durationMs,
provider = result.Provider,
promptVersion = result.PromptVersion
}, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await WriteSseEventAsync(httpContext, "error", new
{
code = "synthesis_error",
message = ex.Message
}, cancellationToken).ConfigureAwait(false);
var durationMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds;
await WriteSseEventAsync(httpContext, "synthesis_end", new
{
totalTokens = 0,
durationMs,
provider = "none",
promptVersion = "search-synth-v1"
}, cancellationToken).ConfigureAwait(false);
}
}
private static async Task<IResult> RebuildIndexAsync(
HttpContext httpContext,
IUnifiedSearchIndexer indexer,
@@ -125,14 +302,19 @@ public static class UnifiedSearchEndpoints
});
}
private static UnifiedSearchFilter? NormalizeFilter(UnifiedSearchApiFilter? filter, string tenant, IReadOnlyList<string>? userScopes = null)
private static UnifiedSearchFilter? NormalizeFilter(
UnifiedSearchApiFilter? filter,
string tenant,
IReadOnlyList<string>? userScopes = null,
string? userId = null)
{
if (filter is null)
{
return new UnifiedSearchFilter
{
Tenant = tenant,
UserScopes = userScopes
UserScopes = userScopes,
UserId = userId
};
}
@@ -180,7 +362,37 @@ public static class UnifiedSearchEndpoints
Service = string.IsNullOrWhiteSpace(filter.Service) ? null : filter.Service.Trim(),
Tags = tags,
Tenant = tenant,
UserScopes = userScopes
UserScopes = userScopes,
UserId = userId
};
}
private static AmbientContext? NormalizeAmbient(UnifiedSearchApiAmbientContext? ambient)
{
if (ambient is null)
{
return null;
}
return new AmbientContext
{
CurrentRoute = string.IsNullOrWhiteSpace(ambient.CurrentRoute) ? null : ambient.CurrentRoute.Trim(),
SessionId = string.IsNullOrWhiteSpace(ambient.SessionId) ? null : ambient.SessionId.Trim(),
ResetSession = ambient.ResetSession,
VisibleEntityKeys = ambient.VisibleEntityKeys is { Count: > 0 }
? ambient.VisibleEntityKeys
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray()
: null,
RecentSearches = ambient.RecentSearches is { Count: > 0 }
? ambient.RecentSearches
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()
: null
};
}
@@ -204,7 +416,17 @@ public static class UnifiedSearchEndpoints
IsPrimary = action.IsPrimary
}).ToArray(),
Metadata = card.Metadata,
Sources = card.Sources.ToArray()
Sources = card.Sources.ToArray(),
Facets = card.Facets.Select(static facet => new UnifiedSearchApiFacet
{
Domain = facet.Domain,
Title = facet.Title,
Snippet = facet.Snippet,
Score = facet.Score,
Metadata = facet.Metadata
}).ToArray(),
Connections = card.Connections.ToArray(),
SynthesisHints = card.SynthesisHints
}).ToArray();
UnifiedSearchApiSynthesis? synthesis = null;
@@ -256,10 +478,95 @@ public static class UnifiedSearchEndpoints
DurationMs = response.Diagnostics.DurationMs,
UsedVector = response.Diagnostics.UsedVector,
Mode = response.Diagnostics.Mode
}
},
Federation = response.Diagnostics.Federation?.Select(static diag => new UnifiedSearchApiFederationDiagnostic
{
Backend = diag.Backend,
ResultCount = diag.ResultCount,
DurationMs = diag.DurationMs,
TimedOut = diag.TimedOut,
Status = diag.Status
}).ToArray()
};
}
private static bool HasSynthesisScope(HttpContext context)
{
var scopes = ResolveUserScopes(context);
if (scopes is null || scopes.Count == 0)
{
return false;
}
return scopes.Contains("search:synthesize", StringComparer.OrdinalIgnoreCase) ||
scopes.Contains("advisory-ai:admin", StringComparer.OrdinalIgnoreCase);
}
private static IReadOnlyList<EntityCard> MapSynthesisCards(IReadOnlyList<UnifiedSearchApiCard>? cards)
{
if (cards is not { Count: > 0 })
{
return [];
}
return cards
.Select(static card => new EntityCard
{
EntityKey = card.EntityKey,
EntityType = card.EntityType,
Domain = card.Domain,
Title = card.Title,
Snippet = card.Snippet,
Score = card.Score,
Severity = card.Severity,
Metadata = card.Metadata,
Sources = card.Sources,
Actions = card.Actions.Select(static action => new EntityCardAction(
action.Label,
action.ActionType,
action.Route,
action.Command,
action.IsPrimary)).ToArray(),
Facets = card.Facets.Select(static facet => new EntityCardFacet
{
Domain = facet.Domain,
Title = facet.Title,
Snippet = facet.Snippet,
Score = facet.Score,
Metadata = facet.Metadata
}).ToArray(),
Connections = card.Connections,
SynthesisHints = card.SynthesisHints
})
.ToArray();
}
private static async Task WriteSseEventAsync(
HttpContext context,
string eventName,
object payload,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(payload);
await context.Response.WriteAsync($"event: {eventName}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"data: {json}\n\n", cancellationToken).ConfigureAwait(false);
await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static IEnumerable<string> Chunk(string content, int size)
{
if (string.IsNullOrEmpty(content) || size <= 0)
{
yield break;
}
for (var index = 0; index < content.Length; index += size)
{
var length = Math.Min(size, content.Length - index);
yield return content.Substring(index, length);
}
}
private static string? ResolveTenant(HttpContext context)
{
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
@@ -374,6 +681,21 @@ public sealed record UnifiedSearchApiRequest
public bool IncludeSynthesis { get; init; } = true;
public bool IncludeDebug { get; init; }
public UnifiedSearchApiAmbientContext? Ambient { get; init; }
}
public sealed record UnifiedSearchApiAmbientContext
{
public string? CurrentRoute { get; init; }
public IReadOnlyList<string>? VisibleEntityKeys { get; init; }
public IReadOnlyList<string>? RecentSearches { get; init; }
public string? SessionId { get; init; }
public bool ResetSession { get; init; }
}
public sealed record UnifiedSearchApiFilter
@@ -393,6 +715,28 @@ public sealed record UnifiedSearchApiFilter
public IReadOnlyList<string>? Tags { get; init; }
}
public sealed record UnifiedSearchSynthesizeApiRequest
{
public string Q { get; init; } = string.Empty;
public IReadOnlyList<UnifiedSearchApiCard> TopCards { get; init; } = [];
public QueryPlan? Plan { get; init; }
public UnifiedSearchSynthesisPreferencesApi? Preferences { get; init; }
}
public sealed record UnifiedSearchSynthesisPreferencesApi
{
public string Depth { get; init; } = "brief";
public int? MaxTokens { get; init; }
public bool IncludeActions { get; init; } = true;
public string Locale { get; init; } = "en";
}
public sealed record UnifiedSearchApiResponse
{
public string Query { get; init; } = string.Empty;
@@ -408,6 +752,8 @@ public sealed record UnifiedSearchApiResponse
public IReadOnlyList<UnifiedSearchApiRefinement>? Refinements { get; init; }
public UnifiedSearchApiDiagnostics Diagnostics { get; init; } = new();
public IReadOnlyList<UnifiedSearchApiFederationDiagnostic>? Federation { get; init; }
}
public sealed record UnifiedSearchApiCard
@@ -431,6 +777,26 @@ public sealed record UnifiedSearchApiCard
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public IReadOnlyList<string> Sources { get; init; } = [];
public IReadOnlyList<UnifiedSearchApiFacet> Facets { get; init; } = [];
public IReadOnlyList<string> Connections { get; init; } = [];
public IReadOnlyDictionary<string, string> SynthesisHints { get; init; } =
new Dictionary<string, string>(StringComparer.Ordinal);
}
public sealed record UnifiedSearchApiFacet
{
public string Domain { get; init; } = "knowledge";
public string Title { get; init; } = string.Empty;
public string Snippet { get; init; } = string.Empty;
public double Score { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
public sealed record UnifiedSearchApiAction
@@ -488,6 +854,19 @@ public sealed record UnifiedSearchApiDiagnostics
public string Mode { get; init; } = "fts-only";
}
public sealed record UnifiedSearchApiFederationDiagnostic
{
public string Backend { get; init; } = string.Empty;
public int ResultCount { get; init; }
public long DurationMs { get; init; }
public bool TimedOut { get; init; }
public string Status { get; init; } = "ok";
}
public sealed record UnifiedSearchRebuildApiResponse
{
public int DomainCount { get; init; }

View File

@@ -106,6 +106,23 @@ public sealed class KnowledgeSearchOptions
[Range(30, 86400)]
public int SearchQualityMonitorIntervalSeconds { get; set; } = 300;
/// <summary>
/// Enables periodic pruning of search analytics/feedback/history tables.
/// </summary>
public bool SearchAnalyticsRetentionEnabled { get; set; } = true;
/// <summary>
/// Retention window in days for search analytics/feedback/history.
/// </summary>
[Range(1, 3650)]
public int SearchAnalyticsRetentionDays { get; set; } = 90;
/// <summary>
/// Interval in seconds for retention pruning.
/// </summary>
[Range(30, 86400)]
public int SearchAnalyticsRetentionIntervalSeconds { get; set; } = 3600;
// ── Live adapter settings (Sprint 103 / G2) ──
/// <summary>Base URL for the Scanner microservice (e.g. "http://scanner:8080").</summary>

View File

@@ -30,6 +30,10 @@
<None Update="KnowledgeSearch/doctor-search-seed.fr.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="models/all-MiniLM-L6-v2.onnx">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>models/all-MiniLM-L6-v2.onnx</TargetPath>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
@@ -37,6 +41,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>

View File

@@ -5,6 +5,11 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| Task ID | Status | Notes |
| --- | --- | --- |
| 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). |
| SPRINT_20260223_100-USRCH-POL-006 | DONE | Deprecation timeline documented in `docs/modules/advisory-ai/CHANGELOG.md`; platform/unified migration criteria closed for sprint 100 task 006. |
| SPRINT_20260224_102-G1-005 | DONE | ONNX missing-model fallback integration evidence added (`G1_OnnxEncoderSelection_MissingModelPath_FallsBackToDeterministicHashEncoder`). |
| SPRINT_20260224_102-G1-004 | DONE | Semantic recall benchmark corpus and assertions complete (48 queries; no exact-term regression; semantic recall uplift proven). |
| SPRINT_20260224_102-G1-001 | DOING | ONNX runtime package + license docs completed; model asset provisioning at `models/all-MiniLM-L6-v2.onnx` still pending deployment packaging. |
| SPRINT_20260222_051-AKS-INGEST | DONE | Added deterministic AKS ingestion controls: markdown allow-list manifest loading, OpenAPI aggregate source path support, and doctor control projection integration for search chunks, including fallback doctor metadata hydration from controls projection fields. |
| AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. |
| AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. |

View File

@@ -174,6 +174,7 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
var policyBadge = ReadString(entry, "policyBadge") ?? string.Empty;
var product = ReadString(entry, "product") ?? component;
var tenant = ReadString(entry, "tenant") ?? "global";
var tenantIdentity = NormalizeTenantForIdentity(tenant);
var tags = ReadStringArray(entry, "tags", ["finding", "vulnerability", severity]);
var title = string.IsNullOrWhiteSpace(component)
@@ -197,8 +198,10 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
bodyParts.Add($"Severity: {severity}");
var body = string.Join("\n", bodyParts);
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", findingId, cveId);
var docId = KnowledgeSearchText.StableId("doc", "finding", findingId);
// Scope ids by tenant to prevent cross-tenant overwrite collisions
// when different tenants have identical finding ids/cve pairs.
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", tenantIdentity, findingId, cveId);
var docId = KnowledgeSearchText.StableId("doc", "finding", tenantIdentity, findingId);
var embedding = _vectorEncoder.Encode(body);
var freshness = ReadTimestamp(entry, "freshness");
@@ -273,13 +276,16 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
var description = ReadString(entry, "description") ?? string.Empty;
var service = ReadString(entry, "service") ?? "scanner";
var tenant = ReadString(entry, "tenant") ?? "global";
var tenantIdentity = NormalizeTenantForIdentity(tenant);
var tags = ReadStringArray(entry, "tags", ["finding", "vulnerability", severity]);
var body = string.IsNullOrWhiteSpace(description)
? $"{title}\nSeverity: {severity}"
: $"{title}\n{description}\nSeverity: {severity}";
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", findingId, cveId);
var docId = KnowledgeSearchText.StableId("doc", "finding", findingId);
// Scope ids by tenant to prevent cross-tenant overwrite collisions
// when different tenants have identical finding ids/cve pairs.
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", tenantIdentity, findingId, cveId);
var docId = KnowledgeSearchText.StableId("doc", "finding", tenantIdentity, findingId);
var embedding = _vectorEncoder.Encode(body);
var freshness = ReadTimestamp(entry, "freshness");
@@ -370,4 +376,11 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string NormalizeTenantForIdentity(string tenant)
{
return string.IsNullOrWhiteSpace(tenant)
? "global"
: tenant.Trim().ToLowerInvariant();
}
}

View File

@@ -0,0 +1,227 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.Vectorization;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
internal sealed class GraphNodeIngestionAdapter : ISearchIngestionAdapter
{
private readonly KnowledgeSearchOptions _knowledgeOptions;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IVectorEncoder _vectorEncoder;
private readonly ILogger<GraphNodeIngestionAdapter> _logger;
public GraphNodeIngestionAdapter(
IOptions<KnowledgeSearchOptions> knowledgeOptions,
IOptions<UnifiedSearchOptions> unifiedOptions,
IVectorEncoder vectorEncoder,
ILogger<GraphNodeIngestionAdapter> logger)
{
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Domain => "graph";
public IReadOnlyList<string> SupportedEntityTypes => ["package", "image", "registry"];
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
{
var path = ResolvePath(_unifiedOptions.Ingestion.GraphSnapshotPath);
if (!File.Exists(path))
{
_logger.LogDebug("Graph snapshot not found at {Path}.", path);
return [];
}
await using var stream = File.OpenRead(path);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
_logger.LogWarning("Graph snapshot at {Path} is not a JSON array.", path);
return [];
}
var allowedKinds = _unifiedOptions.Ingestion.GraphNodeKindFilter
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.Ordinal);
var chunks = new List<UnifiedChunk>();
foreach (var node in document.RootElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = MapNode(node, allowedKinds);
if (chunk is not null)
{
chunks.Add(chunk);
}
}
return chunks;
}
private UnifiedChunk? MapNode(JsonElement node, ISet<string> allowedKinds)
{
if (node.ValueKind != JsonValueKind.Object)
{
return null;
}
var nodeKind = ReadString(node, "kind")?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(nodeKind) || !allowedKinds.Contains(nodeKind))
{
return null;
}
var nodeId = ReadString(node, "nodeId") ?? ReadString(node, "id");
if (string.IsNullOrWhiteSpace(nodeId))
{
return null;
}
var name = ReadString(node, "name") ?? nodeId;
var version = ReadString(node, "version") ?? string.Empty;
var purl = ReadString(node, "purl");
var imageRef = ReadString(node, "imageRef") ?? ReadString(node, "image");
var registry = ReadString(node, "registry");
var digest = ReadString(node, "digest");
var os = ReadString(node, "os");
var arch = ReadString(node, "arch");
var dependencyCount = ReadInt(node, "dependencyCount");
var relationSummary = ReadString(node, "relationshipSummary");
var tenant = ReadString(node, "tenant") ?? "global";
var freshness = ReadTimestamp(node, "freshness") ?? DateTimeOffset.UtcNow;
if (dependencyCount <= 0 &&
string.IsNullOrWhiteSpace(registry) &&
string.IsNullOrWhiteSpace(imageRef) &&
string.IsNullOrWhiteSpace(relationSummary))
{
// Ignore ephemeral nodes with no useful graph/search signal.
return null;
}
var title = nodeKind switch
{
"package" => string.IsNullOrWhiteSpace(version)
? $"package: {name}"
: $"package: {name}@{version}",
"registry" => $"registry: {registry ?? name}",
_ => $"image: {imageRef ?? name}"
};
var bodyParts = new List<string>
{
title,
$"kind: {nodeKind}",
$"name: {name}"
};
if (!string.IsNullOrWhiteSpace(version)) bodyParts.Add($"version: {version}");
if (!string.IsNullOrWhiteSpace(purl)) bodyParts.Add($"purl: {purl}");
if (!string.IsNullOrWhiteSpace(imageRef)) bodyParts.Add($"image: {imageRef}");
if (!string.IsNullOrWhiteSpace(registry)) bodyParts.Add($"registry: {registry}");
if (!string.IsNullOrWhiteSpace(digest)) bodyParts.Add($"digest: {digest}");
if (!string.IsNullOrWhiteSpace(os)) bodyParts.Add($"os: {os}");
if (!string.IsNullOrWhiteSpace(arch)) bodyParts.Add($"arch: {arch}");
bodyParts.Add($"dependencyCount: {dependencyCount}");
if (!string.IsNullOrWhiteSpace(relationSummary)) bodyParts.Add($"relationships: {relationSummary}");
var body = string.Join('\n', bodyParts);
var entityKey = BuildEntityKey(nodeKind, purl, imageRef, registry, name, version);
var entityType = nodeKind is "base_image" ? "image" : nodeKind;
var chunkId = KnowledgeSearchText.StableId("graph", tenant, nodeId, KnowledgeSearchText.StableId(body));
var docId = KnowledgeSearchText.StableId("graph-doc", tenant, nodeId);
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
{
domain = Domain,
tenant,
nodeId,
nodeKind,
purl,
imageRef,
registry,
digest,
os,
arch,
dependencyCount,
relationshipSummary = relationSummary,
freshness = freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
route = $"/ops/graph?node={Uri.EscapeDataString(nodeId)}"
}));
return new UnifiedChunk(
chunkId,
docId,
Kind: "graph_node",
Domain,
title,
body,
_vectorEncoder.Encode(body),
entityKey,
entityType,
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: body.Length,
freshness,
metadata);
}
private string ResolvePath(string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
{
return configuredPath;
}
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
return Path.GetFullPath(Path.Combine(root, configuredPath));
}
private static string BuildEntityKey(
string nodeKind,
string? purl,
string? imageRef,
string? registry,
string name,
string version)
{
return nodeKind switch
{
"package" when !string.IsNullOrWhiteSpace(purl) => $"purl:{purl}",
"package" => $"purl:pkg:{name}@{version}",
"registry" => $"registry:{registry ?? name}",
_ => $"image:{imageRef ?? name}"
};
}
private static string? ReadString(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()?.Trim()
: null;
}
private static int ReadInt(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) &&
prop.ValueKind == JsonValueKind.Number &&
prop.TryGetInt32(out var value)
? value
: 0;
}
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
{
var raw = ReadString(obj, propertyName);
return raw is not null && DateTimeOffset.TryParse(raw, out var timestamp) ? timestamp : null;
}
}

View File

@@ -0,0 +1,237 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.Vectorization;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
internal sealed class OpsDecisionIngestionAdapter : ISearchIngestionAdapter
{
private readonly KnowledgeSearchOptions _knowledgeOptions;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IVectorEncoder _vectorEncoder;
private readonly ILogger<OpsDecisionIngestionAdapter> _logger;
public OpsDecisionIngestionAdapter(
IOptions<KnowledgeSearchOptions> knowledgeOptions,
IOptions<UnifiedSearchOptions> unifiedOptions,
IVectorEncoder vectorEncoder,
ILogger<OpsDecisionIngestionAdapter> logger)
{
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Domain => "opsmemory";
public IReadOnlyList<string> SupportedEntityTypes => ["finding", "package", "image"];
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
{
var path = ResolvePath(_unifiedOptions.Ingestion.OpsMemorySnapshotPath);
if (!File.Exists(path))
{
_logger.LogDebug("OpsMemory snapshot not found at {Path}.", path);
return [];
}
await using var stream = File.OpenRead(path);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return [];
}
var chunks = new List<UnifiedChunk>();
foreach (var decision in document.RootElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = MapDecision(decision);
if (chunk is not null)
{
chunks.Add(chunk);
}
}
return chunks;
}
private UnifiedChunk? MapDecision(JsonElement decision)
{
if (decision.ValueKind != JsonValueKind.Object)
{
return null;
}
var decisionId = ReadString(decision, "decisionId") ?? ReadString(decision, "id");
if (string.IsNullOrWhiteSpace(decisionId))
{
return null;
}
var tenant = ReadString(decision, "tenant") ?? "global";
var decisionType = ReadString(decision, "decisionType") ?? "unknown";
var outcomeStatus = ReadString(decision, "outcomeStatus") ?? "pending";
var subjectRef = ReadString(decision, "subjectRef") ?? ReadString(decision, "cve") ?? string.Empty;
var subjectType = ReadString(decision, "subjectType") ?? GuessSubjectType(subjectRef);
var contextTags = ReadStringArray(decision, "contextTags");
var rationale = ReadString(decision, "rationale") ?? string.Empty;
var severity = ReadString(decision, "severity") ?? "unknown";
var resolutionHours = ReadDouble(decision, "resolutionTimeHours");
var recordedAt = ReadTimestamp(decision, "recordedAt") ?? DateTimeOffset.UtcNow;
var outcomeRecordedAt = ReadTimestamp(decision, "outcomeRecordedAt");
var freshness = outcomeRecordedAt > recordedAt ? outcomeRecordedAt.Value : recordedAt;
var similarityVector = ReadNumberArray(decision, "similarityVector");
var title = $"Decision: {decisionType} for {subjectRef} ({outcomeStatus})";
var body = string.Join('\n', new[]
{
title,
$"decisionType: {decisionType}",
$"outcomeStatus: {outcomeStatus}",
$"subjectRef: {subjectRef}",
$"subjectType: {subjectType}",
$"severity: {severity}",
$"contextTags: {string.Join(",", contextTags)}",
$"resolutionTimeHours: {resolutionHours.ToString(System.Globalization.CultureInfo.InvariantCulture)}",
$"rationale: {rationale}"
});
var entityKey = BuildEntityKey(subjectType, subjectRef);
var chunkId = KnowledgeSearchText.StableId("ops", tenant, decisionId, KnowledgeSearchText.StableId(body));
var docId = KnowledgeSearchText.StableId("ops-doc", tenant, decisionId);
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
{
domain = Domain,
tenant,
decisionId,
decisionType,
outcomeStatus,
subjectRef,
subjectType,
severity,
contextTags,
resolutionTimeHours = resolutionHours,
similarityVector,
freshness = freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
route = $"/ops/opsmemory/decisions/{Uri.EscapeDataString(decisionId)}",
incrementalSignals = new[] { "decision_created", "outcome_recorded" }
}));
return new UnifiedChunk(
chunkId,
docId,
Kind: "ops_decision",
Domain,
title,
body,
_vectorEncoder.Encode(body),
entityKey,
subjectType,
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: body.Length,
freshness,
metadata);
}
private string ResolvePath(string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
{
return configuredPath;
}
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
return Path.GetFullPath(Path.Combine(root, configuredPath));
}
private static string GuessSubjectType(string subjectRef)
{
if (subjectRef.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
return "finding";
}
if (subjectRef.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return "package";
}
if (subjectRef.Contains('/', StringComparison.Ordinal))
{
return "image";
}
return "finding";
}
private static string BuildEntityKey(string subjectType, string subjectRef)
{
return subjectType switch
{
"package" when subjectRef.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) => $"purl:{subjectRef}",
"image" => $"image:{subjectRef}",
_ => subjectRef.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)
? $"cve:{subjectRef.ToUpperInvariant()}"
: $"entity:{subjectRef}"
};
}
private static string? ReadString(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()?.Trim()
: null;
}
private static double ReadDouble(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) &&
prop.ValueKind == JsonValueKind.Number &&
prop.TryGetDouble(out var value)
? value
: 0d;
}
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
{
var raw = ReadString(obj, propertyName);
return raw is not null && DateTimeOffset.TryParse(raw, out var value) ? value : null;
}
private static IReadOnlyList<string> ReadStringArray(JsonElement obj, string propertyName)
{
if (!obj.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
{
return [];
}
return prop.EnumerateArray()
.Where(static value => value.ValueKind == JsonValueKind.String)
.Select(static value => value.GetString())
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static IReadOnlyList<double> ReadNumberArray(JsonElement obj, string propertyName)
{
if (!obj.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
{
return [];
}
return prop.EnumerateArray()
.Where(static value => value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out _))
.Select(static value => value.GetDouble())
.ToArray();
}
}

View File

@@ -175,6 +175,7 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
var scope = bomRef;
var environment = ReadString(entry, "environment") ?? string.Empty;
var tenant = ReadString(entry, "tenant") ?? "global";
var tenantIdentity = NormalizeTenantForIdentity(tenant);
var tags = ReadStringArray(entry, "tags", ["policy", "rule", gateStatus]);
// Map gate status to enforcement level
@@ -205,8 +206,10 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
}
var body = string.Join("\n", bodyParts);
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", ruleId);
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", ruleId);
// Scope ids by tenant to prevent cross-tenant overwrite collisions
// when rule ids are reused in different tenants.
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", tenantIdentity, ruleId);
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", tenantIdentity, ruleId);
var embedding = _vectorEncoder.Encode(body);
var freshness = ReadTimestamp(entry, "evaluated_at")
@@ -283,13 +286,16 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
var decision = ReadString(entry, "decision");
var service = ReadString(entry, "service") ?? "policy";
var tenant = ReadString(entry, "tenant") ?? "global";
var tenantIdentity = NormalizeTenantForIdentity(tenant);
var tags = ReadStringArray(entry, "tags", ["policy", "rule"]);
var body = string.IsNullOrWhiteSpace(decision)
? $"{title}\nRule: {ruleId}\n{description}"
: $"{title}\nRule: {ruleId}\nDecision: {decision}\n{description}";
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", ruleId);
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", ruleId);
// Scope ids by tenant to prevent cross-tenant overwrite collisions
// when rule ids are reused in different tenants.
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", tenantIdentity, ruleId);
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", tenantIdentity, ruleId);
var embedding = _vectorEncoder.Encode(body);
var freshness = ReadTimestamp(entry, "freshness");
@@ -378,4 +384,11 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string NormalizeTenantForIdentity(string tenant)
{
return string.IsNullOrWhiteSpace(tenant)
? "global"
: tenant.Trim().ToLowerInvariant();
}
}

View File

@@ -0,0 +1,190 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.Vectorization;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
internal sealed class ScanResultIngestionAdapter : ISearchIngestionAdapter
{
private readonly KnowledgeSearchOptions _knowledgeOptions;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IVectorEncoder _vectorEncoder;
private readonly ILogger<ScanResultIngestionAdapter> _logger;
public ScanResultIngestionAdapter(
IOptions<KnowledgeSearchOptions> knowledgeOptions,
IOptions<UnifiedSearchOptions> unifiedOptions,
IVectorEncoder vectorEncoder,
ILogger<ScanResultIngestionAdapter> logger)
{
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Domain => "scanner";
public IReadOnlyList<string> SupportedEntityTypes => ["scan"];
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
{
var path = ResolvePath(_unifiedOptions.Ingestion.ScannerSnapshotPath);
if (!File.Exists(path))
{
_logger.LogDebug("Scanner snapshot not found at {Path}.", path);
return [];
}
await using var stream = File.OpenRead(path);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return [];
}
var chunks = new List<UnifiedChunk>();
foreach (var scan in document.RootElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = MapScan(scan);
if (chunk is not null)
{
chunks.Add(chunk);
}
}
return chunks;
}
private UnifiedChunk? MapScan(JsonElement scan)
{
if (scan.ValueKind != JsonValueKind.Object)
{
return null;
}
var scanId = ReadString(scan, "scanId") ?? ReadString(scan, "id");
if (string.IsNullOrWhiteSpace(scanId))
{
return null;
}
var tenant = ReadString(scan, "tenant") ?? "global";
var imageRef = ReadString(scan, "imageRef") ?? ReadString(scan, "image") ?? "unknown-image";
var status = ReadString(scan, "status") ?? "complete";
var scanType = ReadString(scan, "scanType") ?? "vulnerability";
var findingCount = ReadInt(scan, "findingCount");
var criticalCount = ReadInt(scan, "criticalCount");
var durationMs = ReadInt(scan, "durationMs");
var scannerVersion = ReadString(scan, "scannerVersion") ?? string.Empty;
var completedAt = ReadTimestamp(scan, "completedAt") ?? DateTimeOffset.UtcNow;
var policyVerdicts = ReadStringArray(scan, "policyVerdicts");
var title = $"Scan {scanId}: {imageRef} ({findingCount} findings, {criticalCount} critical)";
var body = string.Join('\n', new[]
{
title,
$"scanId: {scanId}",
$"imageRef: {imageRef}",
$"scanType: {scanType}",
$"status: {status}",
$"findingCount: {findingCount}",
$"criticalCount: {criticalCount}",
$"scannerVersion: {scannerVersion}",
$"durationMs: {durationMs}",
$"policyVerdicts: {string.Join(",", policyVerdicts)}"
});
var chunkId = KnowledgeSearchText.StableId("scan", tenant, scanId, KnowledgeSearchText.StableId(body));
var docId = KnowledgeSearchText.StableId("scan-doc", tenant, scanId);
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
{
domain = Domain,
tenant,
scanId,
imageRef,
scanType,
status,
findingCount,
criticalCount,
durationMs,
scannerVersion,
policyVerdicts,
entity_aliases = new[] { $"image:{imageRef}" },
incrementalSignals = new[] { "scan_completed" },
freshness = completedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
route = $"/console/scans/{Uri.EscapeDataString(scanId)}"
}));
return new UnifiedChunk(
chunkId,
docId,
Kind: "scan_result",
Domain,
title,
body,
_vectorEncoder.Encode(body),
EntityKey: $"scan:{scanId}",
EntityType: "scan",
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: body.Length,
completedAt,
metadata);
}
private string ResolvePath(string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
{
return configuredPath;
}
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
return Path.GetFullPath(Path.Combine(root, configuredPath));
}
private static string? ReadString(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()?.Trim()
: null;
}
private static int ReadInt(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) &&
prop.ValueKind == JsonValueKind.Number &&
prop.TryGetInt32(out var value)
? value
: 0;
}
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
{
var raw = ReadString(obj, propertyName);
return raw is not null && DateTimeOffset.TryParse(raw, out var timestamp) ? timestamp : null;
}
private static IReadOnlyList<string> ReadStringArray(JsonElement obj, string propertyName)
{
if (!obj.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
{
return [];
}
return prop.EnumerateArray()
.Where(static value => value.ValueKind == JsonValueKind.String)
.Select(static value => value.GetString())
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}

View File

@@ -0,0 +1,203 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.Vectorization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
internal sealed partial class TimelineEventIngestionAdapter : ISearchIngestionAdapter
{
private readonly KnowledgeSearchOptions _knowledgeOptions;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IVectorEncoder _vectorEncoder;
private readonly ILogger<TimelineEventIngestionAdapter> _logger;
public TimelineEventIngestionAdapter(
IOptions<KnowledgeSearchOptions> knowledgeOptions,
IOptions<UnifiedSearchOptions> unifiedOptions,
IVectorEncoder vectorEncoder,
ILogger<TimelineEventIngestionAdapter> logger)
{
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Domain => "timeline";
public IReadOnlyList<string> SupportedEntityTypes => ["event", "finding", "package", "policy_rule"];
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
{
var path = ResolvePath(_unifiedOptions.Ingestion.TimelineSnapshotPath);
if (!File.Exists(path))
{
_logger.LogDebug("Timeline snapshot not found at {Path}.", path);
return [];
}
await using var stream = File.OpenRead(path);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return [];
}
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, _unifiedOptions.Ingestion.TimelineRetentionDays));
var chunks = new List<UnifiedChunk>();
foreach (var eventItem in document.RootElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = MapEvent(eventItem, cutoff);
if (chunk is not null)
{
chunks.Add(chunk);
}
}
return chunks
.OrderByDescending(static chunk => chunk.Freshness ?? DateTimeOffset.MinValue)
.ThenBy(static chunk => chunk.ChunkId, StringComparer.Ordinal)
.ToArray();
}
private UnifiedChunk? MapEvent(JsonElement eventItem, DateTimeOffset cutoff)
{
if (eventItem.ValueKind != JsonValueKind.Object)
{
return null;
}
var eventId = ReadString(eventItem, "eventId") ?? ReadString(eventItem, "id");
if (string.IsNullOrWhiteSpace(eventId))
{
return null;
}
var timestamp = ReadTimestamp(eventItem, "timestamp")
?? ReadTimestamp(eventItem, "occurredAt")
?? DateTimeOffset.UtcNow;
if (timestamp < cutoff)
{
return null;
}
var tenant = ReadString(eventItem, "tenant") ?? "global";
var actor = ReadString(eventItem, "actor") ?? ReadString(eventItem, "actorName") ?? "unknown";
var action = ReadString(eventItem, "action") ?? "event";
var module = ReadString(eventItem, "module") ?? "unknown";
var targetRef = ReadString(eventItem, "targetRef") ?? string.Empty;
var payloadSummary = ReadString(eventItem, "payloadSummary") ?? ReadString(eventItem, "summary") ?? string.Empty;
var title = $"{action} by {actor} on {module}";
var body = string.Join('\n', new[]
{
title,
$"action: {action}",
$"actor: {actor}",
$"module: {module}",
$"targetRef: {targetRef}",
$"timestamp: {timestamp:O}",
$"summary: {payloadSummary}"
});
var (entityKey, entityType) = ExtractEntity(targetRef);
var chunkId = KnowledgeSearchText.StableId("timeline", tenant, eventId, KnowledgeSearchText.StableId(body));
var docId = KnowledgeSearchText.StableId("timeline-doc", tenant, eventId);
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
{
domain = Domain,
tenant,
eventId,
action,
actor,
module,
targetRef,
payloadSummary,
freshness = timestamp.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
route = $"/ops/audit/events/{Uri.EscapeDataString(eventId)}",
retentionDays = _unifiedOptions.Ingestion.TimelineRetentionDays
}));
return new UnifiedChunk(
chunkId,
docId,
Kind: "audit_event",
Domain,
title,
body,
_vectorEncoder.Encode(body),
entityKey,
entityType,
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: body.Length,
timestamp,
metadata);
}
private string ResolvePath(string configuredPath)
{
if (Path.IsPathRooted(configuredPath))
{
return configuredPath;
}
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
return Path.GetFullPath(Path.Combine(root, configuredPath));
}
private static (string? EntityKey, string EntityType) ExtractEntity(string targetRef)
{
if (string.IsNullOrWhiteSpace(targetRef))
{
return (null, "event");
}
var cveMatch = CveRegex().Match(targetRef);
if (cveMatch.Success)
{
var cve = cveMatch.Value.ToUpperInvariant();
return ($"cve:{cve}", "finding");
}
var purlMatch = PurlRegex().Match(targetRef);
if (purlMatch.Success)
{
var purl = purlMatch.Value;
return ($"purl:{purl}", "package");
}
if (targetRef.Contains("policy", StringComparison.OrdinalIgnoreCase))
{
return ($"policy:{targetRef}", "policy_rule");
}
return (null, "event");
}
private static string? ReadString(JsonElement obj, string propertyName)
{
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()?.Trim()
: null;
}
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
{
var raw = ReadString(obj, propertyName);
return raw is not null && DateTimeOffset.TryParse(raw, out var value) ? value : null;
}
[GeneratedRegex(@"CVE-\d{4}-\d+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex CveRegex();
[GeneratedRegex(@"pkg:[^\s""']+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PurlRegex();
}

View File

@@ -180,6 +180,7 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
var justification = ReadString(entry, "justification") ?? summary;
var product = affectsKey;
var tenant = ReadString(entry, "tenant") ?? "global";
var tenantIdentity = NormalizeTenantForIdentity(tenant);
var tags = ReadStringArray(entry, "tags", ["vex", "statement", status]);
var title = string.IsNullOrWhiteSpace(product)
@@ -201,8 +202,10 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
}
var body = string.Join("\n", bodyParts);
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", statementId);
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", cveId);
// Scope ids by tenant to prevent cross-tenant overwrite collisions
// when statement ids are reused in different tenants.
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", tenantIdentity, statementId);
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", tenantIdentity, cveId);
var embedding = _vectorEncoder.Encode(body);
var freshness = ReadTimestamp(entry, "UpdatedAt") ?? ReadTimestamp(entry, "freshness");
@@ -276,14 +279,17 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
var justification = ReadString(entry, "justification") ?? string.Empty;
var service = ReadString(entry, "service") ?? "vex-hub";
var tenant = ReadString(entry, "tenant") ?? "global";
var tenantIdentity = NormalizeTenantForIdentity(tenant);
var tags = ReadStringArray(entry, "tags", ["vex", "statement", status]);
var title = $"VEX: {cveId} ({status})";
var body = string.IsNullOrWhiteSpace(justification)
? $"{title}\nStatus: {status}"
: $"{title}\nStatus: {status}\nJustification: {justification}";
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", statementId);
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", cveId);
// Scope ids by tenant to prevent cross-tenant overwrite collisions
// when statement ids are reused in different tenants.
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", tenantIdentity, statementId);
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", tenantIdentity, cveId);
var embedding = _vectorEncoder.Encode(body);
var freshness = ReadTimestamp(entry, "freshness");
@@ -382,4 +388,11 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string NormalizeTenantForIdentity(string tenant)
{
return string.IsNullOrWhiteSpace(tenant)
? "global"
: tenant.Trim().ToLowerInvariant();
}
}

View File

@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
internal static class SearchAnalyticsPrivacy
{
public static string NormalizeHistoryQuery(string query)
{
return query.Trim();
}
public static string HashQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
var normalized = query.Trim().ToLowerInvariant();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string? HashUserId(string tenantId, string? userId)
{
if (string.IsNullOrWhiteSpace(userId))
{
return null;
}
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
var normalizedUser = userId.Trim().ToLowerInvariant();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{normalizedTenant}|{normalizedUser}"));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string? RedactFreeform(string? value)
{
_ = value;
return null;
}
}

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
internal sealed class SearchAnalyticsRetentionBackgroundService : BackgroundService
{
private readonly KnowledgeSearchOptions _options;
private readonly SearchAnalyticsService _analyticsService;
private readonly SearchQualityMonitor _qualityMonitor;
private readonly ILogger<SearchAnalyticsRetentionBackgroundService> _logger;
public SearchAnalyticsRetentionBackgroundService(
IOptions<KnowledgeSearchOptions> options,
SearchAnalyticsService analyticsService,
SearchQualityMonitor qualityMonitor,
ILogger<SearchAnalyticsRetentionBackgroundService> logger)
{
_options = options.Value;
_analyticsService = analyticsService;
_qualityMonitor = qualityMonitor;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.SearchAnalyticsRetentionEnabled)
{
_logger.LogDebug("Search analytics retention loop is disabled.");
return;
}
var retentionDays = Math.Max(1, _options.SearchAnalyticsRetentionDays);
var interval = TimeSpan.FromSeconds(Math.Max(30, _options.SearchAnalyticsRetentionIntervalSeconds));
while (!stoppingToken.IsCancellationRequested)
{
try
{
var analytics = await _analyticsService
.PruneExpiredAsync(retentionDays, stoppingToken)
.ConfigureAwait(false);
var quality = await _qualityMonitor
.PruneExpiredAsync(retentionDays, stoppingToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Search retention prune completed. events={Events}, history={History}, feedback={Feedback}, alerts={Alerts}, cutoff={CutoffUtc:O}",
analytics.EventsDeleted,
analytics.HistoryDeleted,
quality.FeedbackDeleted,
quality.AlertsDeleted,
analytics.CutoffUtc);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Search analytics retention prune failed.");
}
try
{
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}

View File

@@ -11,7 +11,7 @@ internal sealed class SearchAnalyticsService
private readonly ILogger<SearchAnalyticsService> _logger;
private readonly object _fallbackLock = new();
private readonly List<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> _fallbackEvents = [];
private readonly Dictionary<(string TenantId, string UserId, string Query), SearchHistoryEntry> _fallbackHistory = new();
private readonly Dictionary<(string TenantId, string UserKey, string Query), SearchHistoryEntry> _fallbackHistory = new();
public SearchAnalyticsService(
IOptions<KnowledgeSearchOptions> options,
@@ -24,9 +24,10 @@ internal sealed class SearchAnalyticsService
public async Task RecordEventAsync(SearchAnalyticsEvent evt, CancellationToken ct = default)
{
var recordedAt = DateTimeOffset.UtcNow;
var persistedEvent = SanitizeEvent(evt);
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
RecordFallbackEvent(evt, recordedAt);
RecordFallbackEvent(persistedEvent, recordedAt);
return;
}
@@ -39,23 +40,23 @@ internal sealed class SearchAnalyticsService
INSERT INTO advisoryai.search_events (tenant_id, user_id, event_type, query, entity_key, domain, result_count, position, duration_ms)
VALUES (@tenant_id, @user_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms)", conn);
cmd.Parameters.AddWithValue("tenant_id", evt.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)evt.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("event_type", evt.EventType);
cmd.Parameters.AddWithValue("query", evt.Query);
cmd.Parameters.AddWithValue("entity_key", (object?)evt.EntityKey ?? DBNull.Value);
cmd.Parameters.AddWithValue("domain", (object?)evt.Domain ?? DBNull.Value);
cmd.Parameters.AddWithValue("result_count", (object?)evt.ResultCount ?? DBNull.Value);
cmd.Parameters.AddWithValue("position", (object?)evt.Position ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("tenant_id", persistedEvent.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)persistedEvent.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("event_type", persistedEvent.EventType);
cmd.Parameters.AddWithValue("query", persistedEvent.Query);
cmd.Parameters.AddWithValue("entity_key", (object?)persistedEvent.EntityKey ?? DBNull.Value);
cmd.Parameters.AddWithValue("domain", (object?)persistedEvent.Domain ?? DBNull.Value);
cmd.Parameters.AddWithValue("result_count", (object?)persistedEvent.ResultCount ?? DBNull.Value);
cmd.Parameters.AddWithValue("position", (object?)persistedEvent.Position ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)persistedEvent.DurationMs ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
RecordFallbackEvent(evt, recordedAt);
RecordFallbackEvent(persistedEvent, recordedAt);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to record search analytics event");
RecordFallbackEvent(evt, recordedAt);
RecordFallbackEvent(persistedEvent, recordedAt);
}
}
@@ -71,7 +72,7 @@ internal sealed class SearchAnalyticsService
{
foreach (var evt in events)
{
RecordFallbackEvent(evt, recordedAt);
RecordFallbackEvent(SanitizeEvent(evt), recordedAt);
}
return;
@@ -84,22 +85,24 @@ internal sealed class SearchAnalyticsService
foreach (var evt in events)
{
var persistedEvent = SanitizeEvent(evt);
await using var cmd = new NpgsqlCommand(@"
INSERT INTO advisoryai.search_events (tenant_id, user_id, event_type, query, entity_key, domain, result_count, position, duration_ms)
VALUES (@tenant_id, @user_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms)", conn);
cmd.Parameters.AddWithValue("tenant_id", evt.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)evt.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("event_type", evt.EventType);
cmd.Parameters.AddWithValue("query", evt.Query);
cmd.Parameters.AddWithValue("entity_key", (object?)evt.EntityKey ?? DBNull.Value);
cmd.Parameters.AddWithValue("domain", (object?)evt.Domain ?? DBNull.Value);
cmd.Parameters.AddWithValue("result_count", (object?)evt.ResultCount ?? DBNull.Value);
cmd.Parameters.AddWithValue("position", (object?)evt.Position ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("tenant_id", persistedEvent.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)persistedEvent.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("event_type", persistedEvent.EventType);
cmd.Parameters.AddWithValue("query", persistedEvent.Query);
cmd.Parameters.AddWithValue("entity_key", (object?)persistedEvent.EntityKey ?? DBNull.Value);
cmd.Parameters.AddWithValue("domain", (object?)persistedEvent.Domain ?? DBNull.Value);
cmd.Parameters.AddWithValue("result_count", (object?)persistedEvent.ResultCount ?? DBNull.Value);
cmd.Parameters.AddWithValue("position", (object?)persistedEvent.Position ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)persistedEvent.DurationMs ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
RecordFallbackEvent(evt, recordedAt);
RecordFallbackEvent(persistedEvent, recordedAt);
}
}
catch (Exception ex)
@@ -107,7 +110,7 @@ internal sealed class SearchAnalyticsService
_logger.LogWarning(ex, "Failed to record search analytics events batch ({Count} events)", events.Count);
foreach (var evt in events)
{
RecordFallbackEvent(evt, recordedAt);
RecordFallbackEvent(SanitizeEvent(evt), recordedAt);
}
}
}
@@ -158,9 +161,21 @@ internal sealed class SearchAnalyticsService
public async Task RecordHistoryAsync(string tenantId, string userId, string query, int resultCount, CancellationToken ct = default)
{
var recordedAt = DateTimeOffset.UtcNow;
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
if (string.IsNullOrWhiteSpace(userKey))
{
return;
}
var normalizedQuery = SearchAnalyticsPrivacy.NormalizeHistoryQuery(query);
if (string.IsNullOrWhiteSpace(normalizedQuery))
{
return;
}
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
RecordFallbackHistory(tenantId, userKey, normalizedQuery, resultCount, recordedAt);
return;
}
@@ -177,8 +192,8 @@ internal sealed class SearchAnalyticsService
result_count = @result_count", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("query", query);
cmd.Parameters.AddWithValue("user_id", userKey);
cmd.Parameters.AddWithValue("query", normalizedQuery);
cmd.Parameters.AddWithValue("result_count", resultCount);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
@@ -193,22 +208,28 @@ internal sealed class SearchAnalyticsService
OFFSET 50
)", conn);
trimCmd.Parameters.AddWithValue("tenant_id", tenantId);
trimCmd.Parameters.AddWithValue("user_id", userId);
trimCmd.Parameters.AddWithValue("user_id", userKey);
await trimCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
RecordFallbackHistory(tenantId, userKey, normalizedQuery, resultCount, recordedAt);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to record search history");
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
RecordFallbackHistory(tenantId, userKey, normalizedQuery, resultCount, recordedAt);
}
}
public async Task<IReadOnlyList<SearchHistoryEntry>> GetHistoryAsync(string tenantId, string userId, int limit = 50, CancellationToken ct = default)
{
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
if (string.IsNullOrWhiteSpace(userKey))
{
return [];
}
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
return GetFallbackHistory(tenantId, userId, limit);
return GetFallbackHistory(tenantId, userKey, limit);
}
var entries = new List<SearchHistoryEntry>();
@@ -226,7 +247,7 @@ internal sealed class SearchAnalyticsService
LIMIT @limit", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("user_id", userKey);
cmd.Parameters.AddWithValue("limit", limit);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
@@ -242,7 +263,7 @@ internal sealed class SearchAnalyticsService
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load search history");
return GetFallbackHistory(tenantId, userId, limit);
return GetFallbackHistory(tenantId, userKey, limit);
}
return entries;
@@ -250,9 +271,15 @@ internal sealed class SearchAnalyticsService
public async Task ClearHistoryAsync(string tenantId, string userId, CancellationToken ct = default)
{
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
if (string.IsNullOrWhiteSpace(userKey))
{
return;
}
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
ClearFallbackHistory(tenantId, userId);
ClearFallbackHistory(tenantId, userKey);
return;
}
@@ -266,15 +293,15 @@ internal sealed class SearchAnalyticsService
WHERE tenant_id = @tenant_id AND user_id = @user_id", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("user_id", userKey);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
ClearFallbackHistory(tenantId, userId);
ClearFallbackHistory(tenantId, userKey);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear search history");
ClearFallbackHistory(tenantId, userId);
ClearFallbackHistory(tenantId, userKey);
}
}
@@ -324,7 +351,7 @@ internal sealed class SearchAnalyticsService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to find similar successful queries for '{Query}'", query);
_logger.LogWarning(ex, "Failed to find similar successful queries for query hash {QueryHash}", SearchAnalyticsPrivacy.HashQuery(query));
return FindFallbackSimilarQueries(tenantId, query, limit);
}
@@ -333,9 +360,15 @@ internal sealed class SearchAnalyticsService
public async Task DeleteHistoryEntryAsync(string tenantId, string userId, string historyId, CancellationToken ct = default)
{
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
if (string.IsNullOrWhiteSpace(userKey))
{
return;
}
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
DeleteFallbackHistoryEntry(tenantId, userKey, historyId);
return;
}
@@ -351,19 +384,84 @@ internal sealed class SearchAnalyticsService
WHERE tenant_id = @tenant_id AND user_id = @user_id AND history_id = @history_id", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("user_id", userId);
cmd.Parameters.AddWithValue("user_id", userKey);
cmd.Parameters.AddWithValue("history_id", Guid.Parse(historyId));
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
DeleteFallbackHistoryEntry(tenantId, userKey, historyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete search history entry");
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
DeleteFallbackHistoryEntry(tenantId, userKey, historyId);
}
}
public async Task<SearchAnalyticsPruneResult> PruneExpiredAsync(int retentionDays, CancellationToken ct = default)
{
var days = Math.Max(0, retentionDays);
var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(days);
var eventsDeleted = 0;
var historyDeleted = 0;
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
{
try
{
await using var conn = new NpgsqlConnection(_options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
await using (var eventsCmd = new NpgsqlCommand(@"
DELETE FROM advisoryai.search_events
WHERE created_at < @cutoff", conn))
{
eventsCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
eventsDeleted = await eventsCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
await using (var historyCmd = new NpgsqlCommand(@"
DELETE FROM advisoryai.search_history
WHERE searched_at < @cutoff", conn))
{
historyCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
historyDeleted = await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prune search analytics/history by retention window.");
}
}
var fallbackEventsDeleted = 0;
var fallbackHistoryDeleted = 0;
lock (_fallbackLock)
{
var eventBefore = _fallbackEvents.Count;
_fallbackEvents.RemoveAll(item => item.RecordedAt < cutoff);
fallbackEventsDeleted = eventBefore - _fallbackEvents.Count;
var historyBefore = _fallbackHistory.Count;
var expiredKeys = _fallbackHistory
.Where(item => item.Value.SearchedAt < cutoff.UtcDateTime)
.Select(item => item.Key)
.ToArray();
foreach (var key in expiredKeys)
{
_fallbackHistory.Remove(key);
}
fallbackHistoryDeleted = historyBefore - _fallbackHistory.Count;
}
return new SearchAnalyticsPruneResult(
EventsDeleted: eventsDeleted + fallbackEventsDeleted,
HistoryDeleted: historyDeleted + fallbackHistoryDeleted,
CutoffUtc: cutoff.UtcDateTime);
}
internal IReadOnlyList<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> GetFallbackEventsSnapshot(
string tenantId,
TimeSpan window)
@@ -407,14 +505,14 @@ internal sealed class SearchAnalyticsService
}
}
private IReadOnlyList<SearchHistoryEntry> GetFallbackHistory(string tenantId, string userId, int limit)
private IReadOnlyList<SearchHistoryEntry> GetFallbackHistory(string tenantId, string userKey, int limit)
{
lock (_fallbackLock)
{
return _fallbackHistory
.Where(item =>
item.Key.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
item.Key.UserId.Equals(userId, StringComparison.OrdinalIgnoreCase))
item.Key.UserKey.Equals(userKey, StringComparison.OrdinalIgnoreCase))
.Select(item => item.Value)
.OrderByDescending(entry => entry.SearchedAt)
.Take(Math.Max(1, limit))
@@ -434,16 +532,16 @@ internal sealed class SearchAnalyticsService
}
}
private void RecordFallbackHistory(string tenantId, string userId, string query, int resultCount, DateTimeOffset recordedAt)
private void RecordFallbackHistory(string tenantId, string userKey, string query, int resultCount, DateTimeOffset recordedAt)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(query))
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(query))
{
return;
}
var normalizedQuery = query.Trim();
(string TenantId, string UserId, string Query) key = (tenantId, userId, normalizedQuery);
var historyId = BuildFallbackHistoryId(tenantId, userId, normalizedQuery);
(string TenantId, string UserKey, string Query) key = (tenantId, userKey, normalizedQuery);
var historyId = BuildFallbackHistoryId(tenantId, userKey, normalizedQuery);
var entry = new SearchHistoryEntry(historyId, normalizedQuery, resultCount, recordedAt.UtcDateTime);
lock (_fallbackLock)
@@ -451,7 +549,7 @@ internal sealed class SearchAnalyticsService
_fallbackHistory[key] = entry;
var overflow = _fallbackHistory.Keys
.Where(k => k.TenantId == key.TenantId && k.UserId == key.UserId)
.Where(k => k.TenantId == key.TenantId && k.UserKey == key.UserKey)
.Select(k => (Key: k, Entry: _fallbackHistory[k]))
.OrderByDescending(item => item.Entry.SearchedAt)
.Skip(50)
@@ -465,12 +563,12 @@ internal sealed class SearchAnalyticsService
}
}
private void ClearFallbackHistory(string tenantId, string userId)
private void ClearFallbackHistory(string tenantId, string userKey)
{
lock (_fallbackLock)
{
var keys = _fallbackHistory.Keys
.Where(key => key.TenantId == tenantId && key.UserId == userId)
.Where(key => key.TenantId == tenantId && key.UserKey == userKey)
.ToArray();
foreach (var key in keys)
@@ -480,7 +578,7 @@ internal sealed class SearchAnalyticsService
}
}
private void DeleteFallbackHistoryEntry(string tenantId, string userId, string historyId)
private void DeleteFallbackHistoryEntry(string tenantId, string userKey, string historyId)
{
if (string.IsNullOrWhiteSpace(historyId))
{
@@ -492,8 +590,8 @@ internal sealed class SearchAnalyticsService
var hit = _fallbackHistory.Keys
.FirstOrDefault(key =>
key.TenantId == tenantId &&
key.UserId == userId &&
BuildFallbackHistoryId(key.TenantId, key.UserId, key.Query).Equals(historyId, StringComparison.Ordinal));
key.UserKey == userKey &&
BuildFallbackHistoryId(key.TenantId, key.UserKey, key.Query).Equals(historyId, StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(hit.TenantId))
{
@@ -526,15 +624,24 @@ internal sealed class SearchAnalyticsService
}
}
private static string BuildFallbackHistoryId(string tenantId, string userId, string query)
private static string BuildFallbackHistoryId(string tenantId, string userKey, string query)
{
var normalizedQuery = query.Trim().ToLowerInvariant();
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes($"{tenantId}|{userId}|{normalizedQuery}"));
System.Text.Encoding.UTF8.GetBytes($"{tenantId}|{userKey}|{normalizedQuery}"));
var guidBytes = hash[..16];
return new Guid(guidBytes).ToString("D");
}
private static SearchAnalyticsEvent SanitizeEvent(SearchAnalyticsEvent evt)
{
return evt with
{
Query = SearchAnalyticsPrivacy.HashQuery(evt.Query),
UserId = SearchAnalyticsPrivacy.HashUserId(evt.TenantId, evt.UserId)
};
}
private static double ComputeTokenSimilarity(string a, string b)
{
var left = a.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
@@ -571,3 +678,8 @@ internal record SearchHistoryEntry(
string Query,
int? ResultCount,
DateTime SearchedAt);
internal sealed record SearchAnalyticsPruneResult(
int EventsDeleted,
int HistoryDeleted,
DateTime CutoffUtc);

View File

@@ -41,9 +41,10 @@ internal sealed class SearchQualityMonitor
public async Task StoreFeedbackAsync(SearchFeedbackEntry entry, CancellationToken ct = default)
{
var createdAt = DateTimeOffset.UtcNow;
var persistedEntry = SanitizeFeedbackEntry(entry);
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
StoreFallbackFeedback(entry, createdAt);
StoreFallbackFeedback(persistedEntry, createdAt);
return;
}
@@ -58,22 +59,22 @@ internal sealed class SearchQualityMonitor
VALUES
(@tenant_id, @user_id, @query, @entity_key, @domain, @position, @signal, @comment)", conn);
cmd.Parameters.AddWithValue("tenant_id", entry.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)entry.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("query", entry.Query);
cmd.Parameters.AddWithValue("entity_key", entry.EntityKey);
cmd.Parameters.AddWithValue("domain", entry.Domain);
cmd.Parameters.AddWithValue("position", entry.Position);
cmd.Parameters.AddWithValue("signal", entry.Signal);
cmd.Parameters.AddWithValue("comment", (object?)entry.Comment ?? DBNull.Value);
cmd.Parameters.AddWithValue("tenant_id", persistedEntry.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)persistedEntry.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("query", persistedEntry.Query);
cmd.Parameters.AddWithValue("entity_key", persistedEntry.EntityKey);
cmd.Parameters.AddWithValue("domain", persistedEntry.Domain);
cmd.Parameters.AddWithValue("position", persistedEntry.Position);
cmd.Parameters.AddWithValue("signal", persistedEntry.Signal);
cmd.Parameters.AddWithValue("comment", (object?)persistedEntry.Comment ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
StoreFallbackFeedback(entry, createdAt);
StoreFallbackFeedback(persistedEntry, createdAt);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to store search feedback");
StoreFallbackFeedback(entry, createdAt);
StoreFallbackFeedback(persistedEntry, createdAt);
}
}
@@ -420,6 +421,11 @@ internal sealed class SearchQualityMonitor
{
metrics.FeedbackScore = Math.Round(feedbackReader.GetDouble(0) * 100, 1);
}
await feedbackReader.CloseAsync().ConfigureAwait(false);
metrics.LowQualityResults = await LoadLowQualityResultsAsync(conn, tenantId, days, ct).ConfigureAwait(false);
metrics.TopQueries = await LoadTopQueriesAsync(conn, tenantId, days, ct).ConfigureAwait(false);
metrics.Trend = await LoadTrendPointsAsync(conn, tenantId, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -474,9 +480,284 @@ internal sealed class SearchQualityMonitor
AvgResultCount = Math.Round(avgResultCount, 1),
FeedbackScore = Math.Round(feedbackScore, 1),
Period = period,
LowQualityResults = BuildFallbackLowQualityRows(tenantId, window),
TopQueries = BuildFallbackTopQueries(tenantId, window),
Trend = BuildFallbackTrendPoints(tenantId),
};
}
private async Task<IReadOnlyList<SearchQualityLowQualityRow>> LoadLowQualityResultsAsync(
NpgsqlConnection conn,
string tenantId,
int days,
CancellationToken ct)
{
var rows = new List<SearchQualityLowQualityRow>();
await using var cmd = new NpgsqlCommand(@"
SELECT
entity_key,
COALESCE(NULLIF(domain, ''), 'unknown') AS domain,
COUNT(*) FILTER (WHERE signal = 'not_helpful')::int AS negative_feedback_count,
COUNT(*)::int AS total_feedback
FROM advisoryai.search_feedback
WHERE tenant_id = @tenant_id
AND created_at > now() - make_interval(days => @days)
GROUP BY entity_key, domain
HAVING COUNT(*) > 0
ORDER BY negative_feedback_count DESC, total_feedback DESC, entity_key ASC
LIMIT 20", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("days", days);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var negative = reader.GetInt32(2);
var total = reader.GetInt32(3);
rows.Add(new SearchQualityLowQualityRow
{
EntityKey = reader.GetString(0),
Domain = reader.GetString(1),
NegativeFeedbackCount = negative,
TotalFeedback = total,
NegativeRate = total == 0 ? 0d : Math.Round((double)negative / total * 100d, 1),
});
}
return rows;
}
private async Task<IReadOnlyList<SearchQualityTopQueryRow>> LoadTopQueriesAsync(
NpgsqlConnection conn,
string tenantId,
int days,
CancellationToken ct)
{
var rows = new List<SearchQualityTopQueryRow>();
await using var cmd = new NpgsqlCommand(@"
WITH query_stats AS (
SELECT
query,
COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result'))::int AS total_searches,
COALESCE(
AVG(result_count) FILTER (WHERE event_type IN ('query', 'zero_result') AND result_count IS NOT NULL),
0
) AS avg_result_count
FROM advisoryai.search_events
WHERE tenant_id = @tenant_id
AND created_at > now() - make_interval(days => @days)
AND query IS NOT NULL
AND btrim(query) <> ''
GROUP BY query
),
feedback_stats AS (
SELECT
lower(query) AS query_key,
COALESCE(AVG(CASE WHEN signal = 'helpful' THEN 1.0 ELSE 0.0 END), 0) AS feedback_score
FROM advisoryai.search_feedback
WHERE tenant_id = @tenant_id
AND created_at > now() - make_interval(days => @days)
AND query IS NOT NULL
AND btrim(query) <> ''
GROUP BY lower(query)
)
SELECT
q.query,
q.total_searches,
q.avg_result_count,
COALESCE(f.feedback_score, 0) AS feedback_score
FROM query_stats q
LEFT JOIN feedback_stats f ON f.query_key = lower(q.query)
ORDER BY q.total_searches DESC, q.query ASC
LIMIT 20", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("days", days);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
rows.Add(new SearchQualityTopQueryRow
{
Query = reader.GetString(0),
TotalSearches = reader.GetInt32(1),
AvgResultCount = Math.Round(reader.GetDouble(2), 1),
FeedbackScore = Math.Round(reader.GetDouble(3) * 100d, 1),
});
}
return rows;
}
private async Task<IReadOnlyList<SearchQualityTrendPoint>> LoadTrendPointsAsync(
NpgsqlConnection conn,
string tenantId,
CancellationToken ct)
{
var points = new List<SearchQualityTrendPoint>();
await using var cmd = new NpgsqlCommand(@"
WITH days AS (
SELECT generate_series(
date_trunc('day', now()) - interval '29 days',
date_trunc('day', now()),
interval '1 day'
) AS day
),
event_agg AS (
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result'))::int AS total_searches,
COUNT(*) FILTER (WHERE event_type = 'zero_result')::int AS zero_results
FROM advisoryai.search_events
WHERE tenant_id = @tenant_id
AND created_at >= date_trunc('day', now()) - interval '29 days'
GROUP BY date_trunc('day', created_at)
),
feedback_agg AS (
SELECT
date_trunc('day', created_at) AS day,
COALESCE(AVG(CASE WHEN signal = 'helpful' THEN 1.0 ELSE 0.0 END), 0) AS feedback_score
FROM advisoryai.search_feedback
WHERE tenant_id = @tenant_id
AND created_at >= date_trunc('day', now()) - interval '29 days'
GROUP BY date_trunc('day', created_at)
)
SELECT
d.day::date,
COALESCE(e.total_searches, 0) AS total_searches,
COALESCE(e.zero_results, 0) AS zero_results,
COALESCE(f.feedback_score, 0) AS feedback_score
FROM days d
LEFT JOIN event_agg e ON e.day = d.day
LEFT JOIN feedback_agg f ON f.day = d.day
ORDER BY d.day ASC", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var day = reader.GetDateTime(0);
var totalSearches = reader.GetInt32(1);
var zeroResults = reader.GetInt32(2);
var feedbackScoreRaw = reader.GetDouble(3);
points.Add(new SearchQualityTrendPoint
{
Day = day,
TotalSearches = totalSearches,
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
FeedbackScore = Math.Round(feedbackScoreRaw * 100d, 1),
});
}
return points;
}
private IReadOnlyList<SearchQualityLowQualityRow> BuildFallbackLowQualityRows(string tenantId, TimeSpan window)
{
return GetFallbackFeedback(tenantId, window)
.GroupBy(item => $"{item.Entry.EntityKey}|{item.Entry.Domain}", StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var total = group.Count();
var negative = group.Count(item => item.Entry.Signal.Equals("not_helpful", StringComparison.Ordinal));
var exemplar = group.First();
return new SearchQualityLowQualityRow
{
EntityKey = exemplar.Entry.EntityKey,
Domain = exemplar.Entry.Domain,
NegativeFeedbackCount = negative,
TotalFeedback = total,
NegativeRate = total == 0 ? 0d : Math.Round((double)negative / total * 100d, 1),
};
})
.Where(row => row.TotalFeedback > 0)
.OrderByDescending(row => row.NegativeFeedbackCount)
.ThenByDescending(row => row.TotalFeedback)
.ThenBy(row => row.EntityKey, StringComparer.OrdinalIgnoreCase)
.Take(20)
.ToArray();
}
private IReadOnlyList<SearchQualityTopQueryRow> BuildFallbackTopQueries(string tenantId, TimeSpan window)
{
var feedbackByQuery = GetFallbackFeedback(tenantId, window)
.Where(item => !string.IsNullOrWhiteSpace(item.Entry.Query))
.GroupBy(item => item.Entry.Query.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group =>
{
var total = group.Count();
var helpful = group.Count(item => item.Entry.Signal.Equals("helpful", StringComparison.Ordinal));
return total == 0 ? 0d : (double)helpful / total * 100d;
},
StringComparer.OrdinalIgnoreCase);
return _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
.Select(item => item.Event)
.Where(evt =>
(evt.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) ||
evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase)) &&
!string.IsNullOrWhiteSpace(evt.Query))
.GroupBy(evt => evt.Query.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var avgResults = group.Where(evt => evt.ResultCount.HasValue)
.Select(evt => evt.ResultCount!.Value)
.DefaultIfEmpty(0)
.Average();
var feedbackScore = feedbackByQuery.TryGetValue(group.Key, out var score) ? score : 0d;
return new SearchQualityTopQueryRow
{
Query = group.Key,
TotalSearches = group.Count(),
AvgResultCount = Math.Round(avgResults, 1),
FeedbackScore = Math.Round(feedbackScore, 1),
};
})
.OrderByDescending(row => row.TotalSearches)
.ThenBy(row => row.Query, StringComparer.OrdinalIgnoreCase)
.Take(20)
.ToArray();
}
private IReadOnlyList<SearchQualityTrendPoint> BuildFallbackTrendPoints(string tenantId)
{
var now = DateTimeOffset.UtcNow;
var start = now.Date.AddDays(-29);
var window = TimeSpan.FromDays(30);
var events = _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
.Where(item => item.Event.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) ||
item.Event.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase))
.ToArray();
var feedback = GetFallbackFeedback(tenantId, window);
var points = new List<SearchQualityTrendPoint>(30);
for (var i = 0; i < 30; i++)
{
var day = start.AddDays(i);
var nextDay = day.AddDays(1);
var dayEvents = events
.Where(evt => evt.RecordedAt.UtcDateTime >= day && evt.RecordedAt.UtcDateTime < nextDay)
.ToArray();
var totalSearches = dayEvents.Length;
var zeroResults = dayEvents.Count(evt => evt.Event.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase));
var dayFeedback = feedback
.Where(item => item.CreatedAt.UtcDateTime >= day && item.CreatedAt.UtcDateTime < nextDay)
.Select(item => item.Entry.Signal)
.ToArray();
var helpful = dayFeedback.Count(signal => signal.Equals("helpful", StringComparison.Ordinal));
points.Add(new SearchQualityTrendPoint
{
Day = day,
TotalSearches = totalSearches,
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
FeedbackScore = dayFeedback.Length == 0 ? 0d : Math.Round((double)helpful / dayFeedback.Length * 100d, 1),
});
}
return points;
}
private async Task<IReadOnlyList<AlertCandidate>> LoadZeroResultCandidatesAsync(
string tenantId,
TimeSpan window,
@@ -716,6 +997,22 @@ internal sealed class SearchQualityMonitor
}
}
private static SearchFeedbackEntry SanitizeFeedbackEntry(SearchFeedbackEntry entry)
{
var normalizedQuery = entry.Query.Trim();
return new SearchFeedbackEntry
{
TenantId = entry.TenantId,
UserId = SearchAnalyticsPrivacy.HashUserId(entry.TenantId, entry.UserId),
Query = SearchAnalyticsPrivacy.HashQuery(normalizedQuery),
EntityKey = entry.EntityKey,
Domain = entry.Domain,
Position = entry.Position,
Signal = entry.Signal,
Comment = SearchAnalyticsPrivacy.RedactFreeform(entry.Comment),
};
}
private void StoreFallbackFeedback(SearchFeedbackEntry entry, DateTimeOffset createdAt)
{
lock (_fallbackLock)
@@ -741,6 +1038,13 @@ internal sealed class SearchQualityMonitor
}
}
internal IReadOnlyList<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> GetFallbackFeedbackSnapshot(
string tenantId,
TimeSpan window)
{
return GetFallbackFeedback(tenantId, window);
}
private static SearchQualityAlertEntry CloneAlertEntry(SearchQualityAlertEntry source)
{
return new SearchQualityAlertEntry
@@ -758,6 +1062,63 @@ internal sealed class SearchQualityMonitor
};
}
public async Task<SearchQualityPruneResult> PruneExpiredAsync(int retentionDays, CancellationToken ct = default)
{
var days = Math.Max(0, retentionDays);
var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(days);
var feedbackDeleted = 0;
var alertsDeleted = 0;
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
{
try
{
await using var conn = new NpgsqlConnection(_options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
await using (var feedbackCmd = new NpgsqlCommand(@"
DELETE FROM advisoryai.search_feedback
WHERE created_at < @cutoff", conn))
{
feedbackCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
feedbackDeleted = await feedbackCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
await using (var alertsCmd = new NpgsqlCommand(@"
DELETE FROM advisoryai.search_quality_alerts
WHERE created_at < @cutoff", conn))
{
alertsCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
alertsDeleted = await alertsCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prune search quality feedback/alerts by retention window.");
}
}
var fallbackFeedbackDeleted = 0;
var fallbackAlertsDeleted = 0;
lock (_fallbackLock)
{
var feedbackBefore = _fallbackFeedback.Count;
_fallbackFeedback.RemoveAll(item => item.CreatedAt < cutoff);
fallbackFeedbackDeleted = feedbackBefore - _fallbackFeedback.Count;
var alertsBefore = _fallbackAlerts.Count;
_fallbackAlerts.RemoveAll(item =>
new DateTimeOffset(DateTime.SpecifyKind(item.CreatedAt, DateTimeKind.Utc), TimeSpan.Zero) < cutoff);
fallbackAlertsDeleted = alertsBefore - _fallbackAlerts.Count;
}
return new SearchQualityPruneResult(
FeedbackDeleted: feedbackDeleted + fallbackFeedbackDeleted,
AlertsDeleted: alertsDeleted + fallbackAlertsDeleted,
CutoffUtc: cutoff.UtcDateTime);
}
private readonly record struct AlertCandidate(
string Query,
int OccurrenceCount,
@@ -810,4 +1171,37 @@ internal sealed class SearchQualityMetricsEntry
public double AvgResultCount { get; set; }
public double FeedbackScore { get; set; }
public string Period { get; set; } = "7d";
public IReadOnlyList<SearchQualityLowQualityRow> LowQualityResults { get; set; } = [];
public IReadOnlyList<SearchQualityTopQueryRow> TopQueries { get; set; } = [];
public IReadOnlyList<SearchQualityTrendPoint> Trend { get; set; } = [];
}
internal sealed class SearchQualityLowQualityRow
{
public string EntityKey { get; set; } = string.Empty;
public string Domain { get; set; } = string.Empty;
public int NegativeFeedbackCount { get; set; }
public int TotalFeedback { get; set; }
public double NegativeRate { get; set; }
}
internal sealed class SearchQualityTopQueryRow
{
public string Query { get; set; } = string.Empty;
public int TotalSearches { get; set; }
public double AvgResultCount { get; set; }
public double FeedbackScore { get; set; }
}
internal sealed class SearchQualityTrendPoint
{
public DateTime Day { get; set; }
public int TotalSearches { get; set; }
public double ZeroResultRate { get; set; }
public double FeedbackScore { get; set; }
}
internal sealed record SearchQualityPruneResult(
int FeedbackDeleted,
int AlertsDeleted,
DateTime CutoffUtc);

View File

@@ -0,0 +1,311 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Cards;
internal sealed class EntityCardAssembler
{
private readonly IEntityAliasService _aliases;
private readonly UnifiedSearchOptions _options;
private readonly ILogger<EntityCardAssembler> _logger;
public EntityCardAssembler(
IEntityAliasService aliases,
IOptions<UnifiedSearchOptions> options,
ILogger<EntityCardAssembler> logger)
{
_aliases = aliases ?? throw new ArgumentNullException(nameof(aliases));
_options = options?.Value ?? new UnifiedSearchOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<EntityCard>> AssembleAsync(
IReadOnlyList<EntityCard> flatCards,
CancellationToken ct)
{
if (flatCards.Count == 0)
{
return [];
}
var grouped = new Dictionary<string, List<EntityCard>>(StringComparer.Ordinal);
for (var index = 0; index < flatCards.Count; index++)
{
var card = flatCards[index];
var canonical = await ResolveCanonicalKeyAsync(card, index, ct).ConfigureAwait(false);
if (!grouped.TryGetValue(canonical, out var list))
{
list = new List<EntityCard>();
grouped[canonical] = list;
}
list.Add(card);
}
var merged = new List<EntityCard>(grouped.Count);
foreach (var entry in grouped.OrderBy(static item => item.Key, StringComparer.Ordinal))
{
ct.ThrowIfCancellationRequested();
var cards = entry.Value
.OrderByDescending(static item => item.Score)
.ThenBy(static item => item.Domain, StringComparer.Ordinal)
.ThenBy(static item => item.EntityType, StringComparer.Ordinal)
.ToArray();
if (cards.Length == 0)
{
continue;
}
var primary = cards[0];
var facets = BuildFacets(cards);
var facetCount = Math.Max(1, facets.Count);
var aggregateScore = primary.Score + 0.1d * Math.Log(facetCount);
var sources = cards
.SelectMany(static card => card.Sources)
.Where(static source => !string.IsNullOrWhiteSpace(source))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static source => source, StringComparer.OrdinalIgnoreCase)
.ToArray();
var (actions, primaryAction) = BuildActions(cards);
var connections = await ResolveConnectionsAsync(entry.Key, ct).ConfigureAwait(false);
var synthesisHints = BuildSynthesisHints(cards, facets);
var snippet = BuildSnippet(cards);
var metadata = MergeMetadata(primary.Metadata, facetCount, connections.Count);
merged.Add(new EntityCard
{
EntityKey = NormalizeMergedEntityKey(entry.Key, primary.EntityKey),
EntityType = primary.EntityType,
Domain = primary.Domain,
Title = primary.Title,
Snippet = snippet,
Score = aggregateScore,
Severity = primary.Severity,
Actions = actions,
Metadata = metadata,
Sources = sources,
Preview = primary.Preview,
Facets = facets,
Connections = connections,
SynthesisHints = synthesisHints
});
}
return merged
.OrderByDescending(static card => card.Score)
.ThenBy(static card => card.EntityType, StringComparer.Ordinal)
.ThenBy(static card => card.EntityKey, StringComparer.Ordinal)
.Take(Math.Max(1, _options.MaxCards))
.ToArray();
}
private async Task<string> ResolveCanonicalKeyAsync(EntityCard card, int index, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(card.EntityKey))
{
return $"__standalone:{index}";
}
// Heuristic alias canonicalization for GHSA cards carrying CVE metadata.
if (card.EntityKey.StartsWith("ghsa:", StringComparison.OrdinalIgnoreCase) &&
card.Metadata is not null &&
card.Metadata.TryGetValue("cveId", out var cveId) &&
!string.IsNullOrWhiteSpace(cveId))
{
return $"cve:{cveId.Trim().ToUpperInvariant()}";
}
try
{
var aliases = await _aliases.ResolveAliasesAsync(card.EntityKey, ct).ConfigureAwait(false);
var canonical = aliases
.Select(static alias => alias.EntityKey)
.Where(static key => !string.IsNullOrWhiteSpace(key))
.OrderBy(static key => key, StringComparer.Ordinal)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(canonical))
{
return canonical;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Entity alias resolution failed for key '{EntityKey}'.", card.EntityKey);
}
return card.EntityKey;
}
private async Task<IReadOnlyList<string>> ResolveConnectionsAsync(string canonicalKey, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(canonicalKey) || canonicalKey.StartsWith("__standalone:", StringComparison.Ordinal))
{
return [];
}
try
{
var aliases = await _aliases.ResolveAliasesAsync(canonicalKey, ct).ConfigureAwait(false);
return aliases
.Select(static alias => alias.EntityKey)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.Where(value => !string.Equals(value, canonicalKey, StringComparison.Ordinal))
.OrderBy(static value => value, StringComparer.Ordinal)
.Take(5)
.ToArray();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to resolve connections for '{CanonicalKey}'.", canonicalKey);
return [];
}
}
private static IReadOnlyList<EntityCardFacet> BuildFacets(IReadOnlyList<EntityCard> cards)
{
var facets = new List<EntityCardFacet>();
foreach (var group in cards
.GroupBy(static card => card.Domain, StringComparer.OrdinalIgnoreCase)
.OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase))
{
var top = group
.OrderByDescending(static card => card.Score)
.ThenBy(static card => card.Title, StringComparer.Ordinal)
.First();
facets.Add(new EntityCardFacet
{
Domain = group.Key,
Title = top.Title,
Snippet = top.Snippet,
Score = top.Score,
Metadata = top.Metadata,
Actions = top.Actions
});
}
return facets;
}
private static (IReadOnlyList<EntityCardAction> Actions, EntityCardAction? PrimaryAction) BuildActions(IReadOnlyList<EntityCard> cards)
{
var actions = new List<EntityCardAction>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var card in cards)
{
foreach (var action in card.Actions)
{
var dedup = $"{action.Label}|{action.ActionType}|{action.Route}|{action.Command}";
if (!seen.Add(dedup))
{
continue;
}
actions.Add(action with { IsPrimary = false });
}
}
var primary = actions.FirstOrDefault() ?? cards.SelectMany(static card => card.Actions).FirstOrDefault();
if (primary is null)
{
return ([], null);
}
var normalized = new List<EntityCardAction> { primary with { IsPrimary = true } };
foreach (var action in actions)
{
if (ActionsEqual(action, primary))
{
continue;
}
normalized.Add(action with { IsPrimary = false });
}
return (normalized, normalized[0]);
}
private static bool ActionsEqual(EntityCardAction left, EntityCardAction right)
{
return string.Equals(left.Label, right.Label, StringComparison.Ordinal) &&
string.Equals(left.ActionType, right.ActionType, StringComparison.Ordinal) &&
string.Equals(left.Route, right.Route, StringComparison.Ordinal) &&
string.Equals(left.Command, right.Command, StringComparison.Ordinal);
}
private static string BuildSnippet(IReadOnlyList<EntityCard> cards)
{
return cards
.Select(static card => card.Snippet)
.Where(static snippet => !string.IsNullOrWhiteSpace(snippet))
.Distinct(StringComparer.Ordinal)
.Take(2)
.DefaultIfEmpty(string.Empty)
.Aggregate((left, right) => string.IsNullOrWhiteSpace(left) ? right : $"{left} | {right}");
}
private static IReadOnlyDictionary<string, string>? MergeMetadata(
IReadOnlyDictionary<string, string>? primaryMetadata,
int facetCount,
int connectionCount)
{
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
if (primaryMetadata is not null)
{
foreach (var entry in primaryMetadata)
{
merged[entry.Key] = entry.Value;
}
}
merged["facetCount"] = facetCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
merged["connectionCount"] = connectionCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
return merged;
}
private static IReadOnlyDictionary<string, string> BuildSynthesisHints(
IReadOnlyList<EntityCard> cards,
IReadOnlyList<EntityCardFacet> facets)
{
var hints = new Dictionary<string, string>(StringComparer.Ordinal);
var top = cards[0];
hints["entityKey"] = top.EntityKey;
hints["entityType"] = top.EntityType;
hints["topDomain"] = top.Domain;
hints["facetCount"] = facets.Count.ToString(System.Globalization.CultureInfo.InvariantCulture);
hints["domains"] = string.Join(",", facets.Select(static facet => facet.Domain));
if (!string.IsNullOrWhiteSpace(top.Severity))
{
hints["severity"] = top.Severity;
}
if (top.Metadata is not null)
{
foreach (var (key, value) in top.Metadata)
{
if (!hints.ContainsKey(key))
{
hints[key] = value;
}
}
}
return hints;
}
private static string NormalizeMergedEntityKey(string mergedKey, string fallback)
{
return mergedKey.StartsWith("__standalone:", StringComparison.Ordinal)
? fallback
: mergedKey;
}
}

View File

@@ -0,0 +1,124 @@
namespace StellaOps.AdvisoryAI.UnifiedSearch.Context;
internal sealed class AmbientContextProcessor
{
private static readonly (string Prefix, string Domain)[] RouteDomainMappings =
[
("/console/findings", "findings"),
("/security/triage", "findings"),
("/security/advisories-vex", "vex"),
("/ops/policies", "policy"),
("/ops/policy", "policy"),
("/ops/graph", "graph"),
("/ops/audit", "timeline"),
("/console/scans", "scanner"),
("/ops/doctor", "knowledge"),
("/docs", "knowledge")
];
public IReadOnlyDictionary<string, double> ApplyRouteBoost(
IReadOnlyDictionary<string, double> baseWeights,
AmbientContext? ambient)
{
var output = new Dictionary<string, double>(baseWeights, StringComparer.OrdinalIgnoreCase);
if (ambient is null || string.IsNullOrWhiteSpace(ambient.CurrentRoute))
{
return output;
}
var domain = ResolveDomainFromRoute(ambient.CurrentRoute);
if (string.IsNullOrWhiteSpace(domain))
{
return output;
}
output[domain] = output.TryGetValue(domain, out var existing)
? existing + 0.10d
: 1.10d;
return output;
}
public IReadOnlyDictionary<string, double> BuildEntityBoostMap(
AmbientContext? ambient,
SearchSessionSnapshot session)
{
var map = new Dictionary<string, double>(StringComparer.Ordinal);
if (ambient?.VisibleEntityKeys is { Count: > 0 })
{
foreach (var entityKey in ambient.VisibleEntityKeys)
{
if (string.IsNullOrWhiteSpace(entityKey))
{
continue;
}
map[entityKey.Trim()] = Math.Max(
map.TryGetValue(entityKey.Trim(), out var existing) ? existing : 0d,
0.20d);
}
}
foreach (var entry in session.EntityBoosts)
{
map[entry.Key] = Math.Max(
map.TryGetValue(entry.Key, out var existing) ? existing : 0d,
entry.Value);
}
return map;
}
public IReadOnlyList<EntityMention> CarryForwardEntities(
IReadOnlyList<EntityMention> currentEntities,
SearchSessionSnapshot session)
{
if (currentEntities.Count > 0 || session.EntityBoosts.Count == 0)
{
return currentEntities;
}
var carried = new List<EntityMention>();
foreach (var entityKey in session.EntityBoosts.Keys)
{
if (entityKey.StartsWith("cve:", StringComparison.OrdinalIgnoreCase))
{
var value = entityKey["cve:".Length..];
carried.Add(new EntityMention(value, "cve", 0, value.Length));
}
else if (entityKey.StartsWith("purl:", StringComparison.OrdinalIgnoreCase))
{
var value = entityKey["purl:".Length..];
carried.Add(new EntityMention(value, "purl", 0, value.Length));
}
else if (entityKey.StartsWith("ghsa:", StringComparison.OrdinalIgnoreCase))
{
var value = entityKey["ghsa:".Length..];
carried.Add(new EntityMention(value, "ghsa", 0, value.Length));
}
}
return carried.Count > 0 ? carried : currentEntities;
}
internal static string? ResolveDomainFromRoute(string route)
{
if (string.IsNullOrWhiteSpace(route))
{
return null;
}
var normalized = route.Trim().ToLowerInvariant();
foreach (var (prefix, domain) in RouteDomainMappings)
{
if (normalized.StartsWith(prefix, StringComparison.Ordinal))
{
return domain;
}
}
return null;
}
}

View File

@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Context;
internal sealed class SearchSessionContextService
{
private readonly ConcurrentDictionary<string, SearchSessionState> _sessions = new(StringComparer.Ordinal);
public SearchSessionSnapshot GetSnapshot(
string tenantId,
string userId,
string sessionId,
DateTimeOffset now,
TimeSpan inactivityTtl)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return SearchSessionSnapshot.Empty;
}
var key = BuildKey(tenantId, userId, sessionId);
if (!_sessions.TryGetValue(key, out var state))
{
return SearchSessionSnapshot.Empty;
}
if (now - state.LastActiveAt > inactivityTtl)
{
_sessions.TryRemove(key, out _);
return SearchSessionSnapshot.Empty;
}
var boosts = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var entry in state.Entities.Values)
{
// 5-minute half-life style decay.
var ageMinutes = Math.Max(0d, (now - entry.LastSeenAt).TotalMinutes);
var decay = Math.Exp(-ageMinutes / 5d);
var boost = 0.15d * decay;
if (boost >= 0.01d)
{
boosts[entry.EntityKey] = Math.Min(0.25d, boost);
}
}
return new SearchSessionSnapshot(
state.SessionId,
state.LastActiveAt,
boosts);
}
public void RecordQuery(
string tenantId,
string userId,
string sessionId,
IReadOnlyList<EntityMention> detectedEntities,
DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return;
}
var key = BuildKey(tenantId, userId, sessionId);
var state = _sessions.GetOrAdd(key, _ => new SearchSessionState(sessionId, now));
lock (state.Sync)
{
state.LastActiveAt = now;
foreach (var mention in detectedEntities)
{
var entityKey = MapMentionToEntityKey(mention);
if (string.IsNullOrWhiteSpace(entityKey))
{
continue;
}
state.Entities[entityKey] = new SessionEntity(entityKey, mention.EntityType, now);
}
}
}
public void Reset(string tenantId, string userId, string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return;
}
_sessions.TryRemove(BuildKey(tenantId, userId, sessionId), out _);
}
private static string BuildKey(string tenantId, string userId, string sessionId)
{
return string.Join(
"|",
string.IsNullOrWhiteSpace(tenantId) ? "global" : tenantId.Trim().ToLowerInvariant(),
string.IsNullOrWhiteSpace(userId) ? "anonymous" : userId.Trim().ToLowerInvariant(),
sessionId.Trim());
}
private static string? MapMentionToEntityKey(EntityMention mention)
{
if (string.IsNullOrWhiteSpace(mention.Value))
{
return null;
}
return mention.EntityType switch
{
"cve" => $"cve:{mention.Value.ToUpperInvariant()}",
"ghsa" => $"ghsa:{mention.Value.ToUpperInvariant()}",
"purl" => $"purl:{mention.Value}",
_ => mention.Value.Contains(':', StringComparison.Ordinal)
? mention.Value
: $"{mention.EntityType}:{mention.Value}"
};
}
private sealed class SearchSessionState
{
public SearchSessionState(string sessionId, DateTimeOffset createdAt)
{
SessionId = sessionId;
LastActiveAt = createdAt;
}
public string SessionId { get; }
public DateTimeOffset LastActiveAt { get; set; }
public Dictionary<string, SessionEntity> Entities { get; } = new(StringComparer.Ordinal);
public object Sync { get; } = new();
}
private sealed record SessionEntity(
string EntityKey,
string EntityType,
DateTimeOffset LastSeenAt);
}
internal sealed record SearchSessionSnapshot(
string SessionId,
DateTimeOffset LastActiveAt,
IReadOnlyDictionary<string, double> EntityBoosts)
{
public static SearchSessionSnapshot Empty { get; } =
new(string.Empty, DateTimeOffset.MinValue, new Dictionary<string, double>(StringComparer.Ordinal));
}

View File

@@ -0,0 +1,465 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.Vectorization;
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Federation;
internal sealed class FederatedSearchDispatcher
{
private readonly UnifiedSearchOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IVectorEncoder _vectorEncoder;
private readonly ILogger<FederatedSearchDispatcher> _logger;
public FederatedSearchDispatcher(
IOptions<UnifiedSearchOptions> options,
IHttpClientFactory httpClientFactory,
IVectorEncoder vectorEncoder,
ILogger<FederatedSearchDispatcher> logger)
{
_options = options?.Value ?? new UnifiedSearchOptions();
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FederatedSearchDispatchResult> DispatchAsync(
string query,
QueryPlan plan,
UnifiedSearchFilter? filter,
CancellationToken ct)
{
if (!_options.Federation.Enabled)
{
return FederatedSearchDispatchResult.Disabled;
}
var timeoutPerBackend = 100;
var descriptors = BuildBackendDescriptors(plan);
if (descriptors.Count == 0)
{
return FederatedSearchDispatchResult.NoBackendsConfigured;
}
var timeoutBudget = Math.Max(100, _options.Federation.TimeoutBudgetMs);
timeoutPerBackend = Math.Max(100, timeoutBudget / descriptors.Count);
var tasks = descriptors
.Select(static descriptor => descriptor.QueryTask())
.ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
var backendResults = tasks.Select(static task => task.Result).ToArray();
var diagnostics = backendResults
.Select(static result => result.Diagnostic)
.OrderBy(static diagnostic => diagnostic.Backend, StringComparer.Ordinal)
.ToArray();
var normalizedRows = NormalizeAndDeduplicate(backendResults.SelectMany(static result => result.Chunks));
return new FederatedSearchDispatchResult(normalizedRows, diagnostics);
BackendDescriptor Build(
string backendName,
string endpoint,
string clientName,
string domain,
string kind)
{
return new BackendDescriptor(
backendName,
domain,
() => QueryBackendAsync(
backendName,
endpoint,
clientName,
domain,
kind,
query,
filter?.Tenant ?? "global",
timeoutPerBackend,
ct));
}
List<BackendDescriptor> BuildBackendDescriptors(QueryPlan queryPlan)
{
var list = new List<BackendDescriptor>();
var threshold = _options.Federation.FederationThreshold;
if (!string.IsNullOrWhiteSpace(_options.Federation.ConsoleEndpoint) &&
GetDomainWeight(queryPlan, "findings") >= threshold)
{
list.Add(Build(
"console",
_options.Federation.ConsoleEndpoint,
"scanner-internal",
"findings",
"finding"));
}
if (!string.IsNullOrWhiteSpace(_options.Federation.GraphEndpoint) &&
GetDomainWeight(queryPlan, "graph") >= threshold)
{
list.Add(Build(
"graph",
_options.Federation.GraphEndpoint,
"graph-internal",
"graph",
"graph_node"));
}
if (!string.IsNullOrWhiteSpace(_options.Federation.TimelineEndpoint) &&
GetDomainWeight(queryPlan, "timeline") >= threshold)
{
list.Add(Build(
"timeline",
_options.Federation.TimelineEndpoint,
"timeline-internal",
"timeline",
"audit_event"));
}
return list;
}
}
private async Task<BackendQueryResult> QueryBackendAsync(
string backendName,
string endpoint,
string clientName,
string domain,
string kind,
string query,
string tenant,
int timeoutMs,
CancellationToken ct)
{
var stopwatch = Stopwatch.StartNew();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
try
{
var client = _httpClientFactory.CreateClient(clientName);
var url = $"{endpoint.TrimEnd('/')}/v1/search/query";
using var response = await client.PostAsJsonAsync(
url,
new
{
q = query,
k = _options.Federation.MaxFederatedResults,
tenant
},
timeoutCts.Token)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
stopwatch.Stop();
return new BackendQueryResult(
[],
new FederationBackendDiagnostic(
backendName,
0,
(long)stopwatch.Elapsed.TotalMilliseconds,
TimedOut: false,
Status: $"http_{(int)response.StatusCode}"));
}
await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token).ConfigureAwait(false);
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token).ConfigureAwait(false);
var chunks = ParseBackendResponse(json.RootElement, domain, kind, tenant, query);
stopwatch.Stop();
return new BackendQueryResult(
chunks,
new FederationBackendDiagnostic(
backendName,
chunks.Count,
(long)stopwatch.Elapsed.TotalMilliseconds,
TimedOut: false,
Status: "ok"));
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
stopwatch.Stop();
return new BackendQueryResult(
[],
new FederationBackendDiagnostic(
backendName,
0,
(long)stopwatch.Elapsed.TotalMilliseconds,
TimedOut: true,
Status: "timeout"));
}
catch (Exception ex) when (ex is HttpRequestException or JsonException)
{
_logger.LogDebug(ex, "Federated backend '{Backend}' query failed.", backendName);
stopwatch.Stop();
return new BackendQueryResult(
[],
new FederationBackendDiagnostic(
backendName,
0,
(long)stopwatch.Elapsed.TotalMilliseconds,
TimedOut: false,
Status: "failed"));
}
}
private IReadOnlyList<KnowledgeChunkRow> NormalizeAndDeduplicate(IEnumerable<UnifiedChunk> chunks)
{
var byKey = new Dictionary<string, UnifiedChunk>(StringComparer.Ordinal);
foreach (var chunk in chunks)
{
var dedupKey = $"{chunk.Domain}|{chunk.EntityKey ?? chunk.ChunkId}";
if (!byKey.TryGetValue(dedupKey, out var existing))
{
byKey[dedupKey] = chunk;
continue;
}
if ((chunk.Freshness ?? DateTimeOffset.MinValue) > (existing.Freshness ?? DateTimeOffset.MinValue))
{
byKey[dedupKey] = chunk;
}
}
return byKey.Values
.OrderByDescending(static chunk => chunk.Freshness ?? DateTimeOffset.MinValue)
.ThenBy(static chunk => chunk.ChunkId, StringComparer.Ordinal)
.Select(chunk => new KnowledgeChunkRow(
chunk.ChunkId,
chunk.DocId,
chunk.Kind,
chunk.Anchor,
chunk.SectionPath,
chunk.SpanStart,
chunk.SpanEnd,
chunk.Title,
chunk.Body,
KnowledgeSearchText.BuildSnippet(chunk.Body, string.Empty),
chunk.Metadata,
chunk.Embedding,
LexicalScore: 0.15d))
.ToArray();
}
private IReadOnlyList<UnifiedChunk> ParseBackendResponse(
JsonElement root,
string domain,
string kind,
string tenant,
string query)
{
var items = ExtractItems(root);
if (items.Count == 0)
{
return [];
}
var chunks = new List<UnifiedChunk>(items.Count);
foreach (var item in items)
{
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
var title = ReadString(item, "title")
?? ReadString(item, "name")
?? ReadString(item, "id")
?? "result";
var body = ReadString(item, "snippet")
?? ReadString(item, "body")
?? ReadString(item, "description")
?? title;
var entityKey = ReadString(item, "entityKey")
?? ReadString(item, "entity_key")
?? GuessEntityKey(item, domain);
var entityType = ReadString(item, "entityType")
?? ReadString(item, "entity_type")
?? GuessEntityType(domain);
var freshness = ReadTimestamp(item, "freshness")
?? ReadTimestamp(item, "updatedAt")
?? ReadTimestamp(item, "createdAt")
?? DateTimeOffset.UtcNow;
var chunkId = KnowledgeSearchText.StableId(
"chunk",
"federated",
domain,
tenant,
entityKey ?? title,
freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture));
var docId = KnowledgeSearchText.StableId("doc", "federated", domain, tenant, entityKey ?? title);
var metadata = BuildMetadata(domain, entityKey, entityType, tenant, freshness, query, item);
chunks.Add(new UnifiedChunk(
chunkId,
docId,
kind,
domain,
title,
body,
_vectorEncoder.Encode(body),
entityKey,
entityType,
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: body.Length,
Freshness: freshness,
metadata));
}
return chunks;
}
private static IReadOnlyList<JsonElement> ExtractItems(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Array)
{
return root.EnumerateArray().ToArray();
}
if (root.ValueKind != JsonValueKind.Object)
{
return [];
}
foreach (var key in new[] { "items", "results", "cards" })
{
if (root.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.Array)
{
return property.EnumerateArray().ToArray();
}
}
return [];
}
private static JsonDocument BuildMetadata(
string domain,
string? entityKey,
string entityType,
string tenant,
DateTimeOffset freshness,
string query,
JsonElement sourceItem)
{
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["domain"] = domain,
["entity_key"] = entityKey,
["entity_type"] = entityType,
["tenant"] = tenant,
["freshness"] = freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
["federatedQuery"] = query
};
foreach (var property in sourceItem.EnumerateObject())
{
if (!payload.ContainsKey(property.Name))
{
payload[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => property.Value.GetRawText()
};
}
}
return JsonDocument.Parse(JsonSerializer.Serialize(payload));
}
private static string? ReadString(JsonElement obj, string propertyName)
{
if (!obj.TryGetProperty(propertyName, out var property) ||
property.ValueKind != JsonValueKind.String)
{
return null;
}
var value = property.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
{
var raw = ReadString(obj, propertyName);
return raw is not null && DateTimeOffset.TryParse(raw, out var parsed) ? parsed : null;
}
private static string GuessEntityType(string domain)
{
return domain switch
{
"findings" => "finding",
"graph" => "graph_node",
"timeline" => "event",
_ => "result"
};
}
private static string? GuessEntityKey(JsonElement item, string domain)
{
var cve = ReadString(item, "cve") ?? ReadString(item, "cveId");
if (!string.IsNullOrWhiteSpace(cve))
{
return $"cve:{cve}";
}
var purl = ReadString(item, "purl");
if (!string.IsNullOrWhiteSpace(purl))
{
return $"purl:{purl}";
}
var image = ReadString(item, "image") ?? ReadString(item, "imageRef");
if (!string.IsNullOrWhiteSpace(image))
{
return $"image:{image}";
}
var id = ReadString(item, "id");
return string.IsNullOrWhiteSpace(id) ? null : $"{domain}:{id}";
}
private static double GetDomainWeight(QueryPlan plan, string domain)
{
return plan.DomainWeights.TryGetValue(domain, out var value) ? value : 1d;
}
private sealed record BackendDescriptor(
string BackendName,
string Domain,
Func<Task<BackendQueryResult>> QueryTask);
private sealed record BackendQueryResult(
IReadOnlyList<UnifiedChunk> Chunks,
FederationBackendDiagnostic Diagnostic);
}
internal sealed record FederatedSearchDispatchResult(
IReadOnlyList<KnowledgeChunkRow> Rows,
IReadOnlyList<FederationBackendDiagnostic> Diagnostics)
{
public static FederatedSearchDispatchResult Disabled { get; } =
new([], [new FederationBackendDiagnostic("federation", 0, 0, TimedOut: false, Status: "disabled")]);
public static FederatedSearchDispatchResult NoBackendsConfigured { get; } =
new([], [new FederationBackendDiagnostic("federation", 0, 0, TimedOut: false, Status: "not_configured")]);
}

View File

@@ -0,0 +1,129 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Federation;
internal sealed class HttpGraphNeighborProvider : IGraphNeighborProvider
{
private readonly UnifiedSearchOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<HttpGraphNeighborProvider> _logger;
public HttpGraphNeighborProvider(
IOptions<UnifiedSearchOptions> options,
IHttpClientFactory httpClientFactory,
ILogger<HttpGraphNeighborProvider> logger)
{
_options = options?.Value ?? new UnifiedSearchOptions();
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<string>> GetOneHopNeighborsAsync(
string entityKey,
string tenant,
int limit,
TimeSpan timeout,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(entityKey) ||
string.IsNullOrWhiteSpace(_options.Federation.GraphEndpoint))
{
return [];
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
var client = _httpClientFactory.CreateClient("graph-internal");
var url = $"{_options.Federation.GraphEndpoint.TrimEnd('/')}/v1/graph/neighbors";
using var response = await client.PostAsJsonAsync(
url,
new
{
entityKey,
tenant,
depth = 1,
limit = Math.Max(1, limit)
},
timeoutCts.Token).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return [];
}
await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token).ConfigureAwait(false);
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token).ConfigureAwait(false);
return ParseNeighborEntityKeys(json.RootElement);
}
catch (Exception ex) when (ex is TaskCanceledException or HttpRequestException or JsonException)
{
_logger.LogDebug(ex, "Graph neighbor lookup failed for entity '{EntityKey}'.", entityKey);
return [];
}
}
private static IReadOnlyList<string> ParseNeighborEntityKeys(JsonElement root)
{
var keys = new HashSet<string>(StringComparer.Ordinal);
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var item in root.EnumerateArray())
{
AddKey(item, keys);
}
return keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
}
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("neighbors", out var neighbors) &&
neighbors.ValueKind == JsonValueKind.Array)
{
foreach (var item in neighbors.EnumerateArray())
{
AddKey(item, keys);
}
}
return keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
}
private static void AddKey(JsonElement item, ISet<string> keys)
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
keys.Add(value.Trim());
}
return;
}
if (item.ValueKind == JsonValueKind.Object)
{
if (item.TryGetProperty("entityKey", out var entityKey) &&
entityKey.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(entityKey.GetString()))
{
keys.Add(entityKey.GetString()!.Trim());
return;
}
if (item.TryGetProperty("entity_key", out var snakeCaseKey) &&
snakeCaseKey.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(snakeCaseKey.GetString()))
{
keys.Add(snakeCaseKey.GetString()!.Trim());
}
}
}
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.AdvisoryAI.UnifiedSearch.Federation;
internal interface IGraphNeighborProvider
{
Task<IReadOnlyList<string>> GetOneHopNeighborsAsync(
string entityKey,
string tenant,
int limit,
TimeSpan timeout,
CancellationToken ct);
}

View File

@@ -5,37 +5,28 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
internal sealed class DomainWeightCalculator
{
private const double BaseWeight = 1.0;
private const double CveBoostFindings = 0.35;
private const double CveBoostVex = 0.30;
private const double CveBoostGraph = 0.25;
private const double SecurityBoostFindings = 0.20;
private const double SecurityBoostVex = 0.15;
private const double PolicyBoostPolicy = 0.30;
private const double TroubleshootBoostKnowledge = 0.15;
private const double TroubleshootBoostOpsMemory = 0.10;
// Role-based bias constants (Sprint 106 / G6)
private const double RoleScannerFindingsBoost = 0.15;
private const double RoleScannerVexBoost = 0.10;
private const double RolePolicyBoost = 0.20;
private const double RoleOpsKnowledgeBoost = 0.15;
private const double RoleOpsMemoryBoost = 0.10;
private const double RoleReleasePolicyBoost = 0.10;
private const double RoleReleaseFindingsBoost = 0.10;
private readonly EntityExtractor _entityExtractor;
private readonly IntentClassifier _intentClassifier;
private readonly KnowledgeSearchOptions _options;
private readonly UnifiedSearchOptions _unifiedOptions;
public DomainWeightCalculator(
EntityExtractor entityExtractor,
IntentClassifier intentClassifier,
IOptions<KnowledgeSearchOptions> options)
: this(entityExtractor, intentClassifier, options, null)
{
_entityExtractor = entityExtractor ?? throw new ArgumentNullException(nameof(entityExtractor));
}
public DomainWeightCalculator(
EntityExtractor entityExtractor,
IntentClassifier intentClassifier,
IOptions<KnowledgeSearchOptions> options,
IOptions<UnifiedSearchOptions>? unifiedOptions)
{
ArgumentNullException.ThrowIfNull(entityExtractor);
_intentClassifier = intentClassifier ?? throw new ArgumentNullException(nameof(intentClassifier));
_options = options?.Value ?? new KnowledgeSearchOptions();
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
}
public IReadOnlyDictionary<string, double> ComputeWeights(
@@ -43,16 +34,8 @@ internal sealed class DomainWeightCalculator
IReadOnlyList<EntityMention> entities,
UnifiedSearchFilter? filters)
{
var weights = new Dictionary<string, double>(StringComparer.Ordinal)
{
["knowledge"] = BaseWeight,
["findings"] = BaseWeight,
["vex"] = BaseWeight,
["policy"] = BaseWeight,
["graph"] = BaseWeight,
["ops_memory"] = BaseWeight,
["timeline"] = BaseWeight
};
var tuning = _unifiedOptions.Weighting ?? new UnifiedSearchWeightingOptions();
var weights = BuildBaseWeights();
var hasCve = entities.Any(static e =>
e.EntityType.Equals("cve", StringComparison.OrdinalIgnoreCase) ||
@@ -60,27 +43,46 @@ internal sealed class DomainWeightCalculator
if (hasCve)
{
weights["findings"] += CveBoostFindings;
weights["vex"] += CveBoostVex;
weights["graph"] += CveBoostGraph;
weights["findings"] += tuning.CveBoostFindings;
weights["vex"] += tuning.CveBoostVex;
weights["graph"] += tuning.CveBoostGraph;
}
var hasPackageLikeEntity = entities.Any(static e =>
e.EntityType.Equals("purl", StringComparison.OrdinalIgnoreCase) ||
e.EntityType.Equals("package", StringComparison.OrdinalIgnoreCase) ||
e.EntityType.Equals("image", StringComparison.OrdinalIgnoreCase));
if (hasPackageLikeEntity ||
query.Contains("package", StringComparison.OrdinalIgnoreCase) ||
query.Contains("image", StringComparison.OrdinalIgnoreCase))
{
weights["graph"] += tuning.PackageBoostGraph;
weights["scanner"] += tuning.PackageBoostScanner;
weights["findings"] += tuning.PackageBoostFindings;
}
if (_intentClassifier.HasSecurityIntent(query))
{
weights["findings"] += SecurityBoostFindings;
weights["vex"] += SecurityBoostVex;
weights["findings"] += tuning.SecurityBoostFindings;
weights["vex"] += tuning.SecurityBoostVex;
}
if (_intentClassifier.HasPolicyIntent(query))
{
weights["policy"] += PolicyBoostPolicy;
weights["policy"] += tuning.PolicyBoostPolicy;
}
var intent = _intentClassifier.Classify(query);
if (intent == "troubleshoot")
{
weights["knowledge"] += TroubleshootBoostKnowledge;
weights["ops_memory"] += TroubleshootBoostOpsMemory;
weights["knowledge"] += tuning.TroubleshootBoostKnowledge;
weights["opsmemory"] += tuning.TroubleshootBoostOpsMemory;
}
if (IsTimelineHeavyQuery(query))
{
weights["timeline"] += tuning.AuditBoostTimeline;
weights["opsmemory"] += tuning.AuditBoostOpsMemory;
}
if (filters?.Domains is { Count: > 0 })
@@ -89,7 +91,7 @@ internal sealed class DomainWeightCalculator
{
if (weights.ContainsKey(domain))
{
weights[domain] += 0.25;
weights[domain] += tuning.FilterDomainMatchBoost;
}
}
}
@@ -97,41 +99,82 @@ internal sealed class DomainWeightCalculator
// Role-based domain bias (Sprint 106 / G6)
if (_options.RoleBasedBiasEnabled && filters?.UserScopes is { Count: > 0 })
{
ApplyRoleBasedBias(weights, filters.UserScopes);
ApplyRoleBasedBias(weights, filters.UserScopes, tuning);
}
return weights;
}
private static void ApplyRoleBasedBias(Dictionary<string, double> weights, IReadOnlyList<string> scopes)
private Dictionary<string, double> BuildBaseWeights()
{
var defaults = new Dictionary<string, double>(StringComparer.Ordinal)
{
["knowledge"] = 1.0,
["findings"] = 1.0,
["vex"] = 1.0,
["policy"] = 1.0,
["platform"] = 1.0,
["graph"] = 1.0,
["timeline"] = 1.0,
["scanner"] = 1.0,
["opsmemory"] = 1.0
};
foreach (var entry in _unifiedOptions.BaseDomainWeights)
{
defaults[entry.Key] = entry.Value;
}
return defaults;
}
private static bool IsTimelineHeavyQuery(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return false;
}
return query.Contains("timeline", StringComparison.OrdinalIgnoreCase) ||
query.Contains("audit", StringComparison.OrdinalIgnoreCase) ||
query.Contains("approved", StringComparison.OrdinalIgnoreCase) ||
query.Contains("waiver", StringComparison.OrdinalIgnoreCase) ||
query.Contains("who", StringComparison.OrdinalIgnoreCase) ||
query.Contains("last week", StringComparison.OrdinalIgnoreCase);
}
private static void ApplyRoleBasedBias(
Dictionary<string, double> weights,
IReadOnlyList<string> scopes,
UnifiedSearchWeightingOptions tuning)
{
var scopeSet = new HashSet<string>(scopes, StringComparer.OrdinalIgnoreCase);
// scanner:read or findings:read -> boost findings + vex
if (scopeSet.Contains("scanner:read") || scopeSet.Contains("findings:read"))
{
weights["findings"] += RoleScannerFindingsBoost;
weights["vex"] += RoleScannerVexBoost;
weights["findings"] += tuning.RoleScannerFindingsBoost;
weights["vex"] += tuning.RoleScannerVexBoost;
}
// policy:read or policy:write -> boost policy
if (scopeSet.Contains("policy:read") || scopeSet.Contains("policy:write"))
{
weights["policy"] += RolePolicyBoost;
weights["policy"] += tuning.RolePolicyBoost;
}
// ops:read or doctor:run -> boost knowledge + ops_memory
// ops:read or doctor:run -> boost knowledge + opsmemory
if (scopeSet.Contains("ops:read") || scopeSet.Contains("doctor:run"))
{
weights["knowledge"] += RoleOpsKnowledgeBoost;
weights["ops_memory"] += RoleOpsMemoryBoost;
weights["knowledge"] += tuning.RoleOpsKnowledgeBoost;
weights["opsmemory"] += tuning.RoleOpsMemoryBoost;
}
// release:approve -> boost policy + findings
if (scopeSet.Contains("release:approve"))
{
weights["policy"] += RoleReleasePolicyBoost;
weights["findings"] += RoleReleaseFindingsBoost;
weights["policy"] += tuning.RoleReleasePolicyBoost;
weights["findings"] += tuning.RoleReleaseFindingsBoost;
}
}
}

View File

@@ -0,0 +1,124 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
internal sealed class GravityBoostCalculator
{
private readonly UnifiedSearchOptions _options;
private readonly IGraphNeighborProvider _neighbors;
private readonly ILogger<GravityBoostCalculator> _logger;
public GravityBoostCalculator(
IOptions<UnifiedSearchOptions> options,
IGraphNeighborProvider neighbors,
ILogger<GravityBoostCalculator> logger)
{
_options = options?.Value ?? new UnifiedSearchOptions();
_neighbors = neighbors ?? throw new ArgumentNullException(nameof(neighbors));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyDictionary<string, double>> BuildGravityMapAsync(
IReadOnlyList<EntityMention> detectedEntities,
string tenant,
CancellationToken ct)
{
if (!_options.GravityBoost.Enabled || detectedEntities.Count == 0)
{
return new Dictionary<string, double>(StringComparer.Ordinal);
}
var mentionKeys = detectedEntities
.Select(MapMentionToEntityKey)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (mentionKeys.Length == 0)
{
return new Dictionary<string, double>(StringComparer.Ordinal);
}
var timeout = TimeSpan.FromMilliseconds(_options.GravityBoost.TimeoutMs);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
var oneHopTasks = mentionKeys
.Select(async mentionKey =>
{
var neighbors = await _neighbors.GetOneHopNeighborsAsync(
mentionKey!,
tenant,
_options.GravityBoost.MaxNeighborsPerEntity,
timeout,
timeoutCts.Token)
.ConfigureAwait(false);
return (mentionKey, neighbors);
})
.ToArray();
await Task.WhenAll(oneHopTasks).ConfigureAwait(false);
var boostMap = new Dictionary<string, double>(StringComparer.Ordinal);
var totalNeighbors = 0;
foreach (var task in oneHopTasks)
{
var (_, neighbors) = task.Result;
foreach (var neighbor in neighbors)
{
if (string.IsNullOrWhiteSpace(neighbor) ||
mentionKeys.Contains(neighbor, StringComparer.Ordinal))
{
continue;
}
boostMap[neighbor] = Math.Max(
boostMap.TryGetValue(neighbor, out var existing) ? existing : 0d,
_options.GravityBoost.OneHopBoost);
totalNeighbors++;
if (totalNeighbors >= _options.GravityBoost.MaxTotalNeighbors)
{
return boostMap;
}
}
}
return boostMap;
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
_logger.LogDebug(
"Gravity boost lookup timed out after {TimeoutMs}ms; returning empty map.",
_options.GravityBoost.TimeoutMs);
return new Dictionary<string, double>(StringComparer.Ordinal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Gravity boost lookup failed; returning empty map.");
return new Dictionary<string, double>(StringComparer.Ordinal);
}
}
private static string? MapMentionToEntityKey(EntityMention mention)
{
if (string.IsNullOrWhiteSpace(mention.Value))
{
return null;
}
return mention.EntityType switch
{
"cve" => $"cve:{mention.Value.ToUpperInvariant()}",
"ghsa" => $"ghsa:{mention.Value.ToUpperInvariant()}",
"purl" => $"purl:{mention.Value}",
_ => null
};
}
}

View File

@@ -0,0 +1,29 @@
[
{
"nodeId": "pkg-lodash-4.17.21",
"kind": "package",
"name": "lodash",
"version": "4.17.21",
"purl": "pkg:npm/lodash@4.17.21",
"registry": "npmjs.org",
"dependencyCount": 12,
"relationshipSummary": "depends-on: nodejs; contained-in: registry.io/app:v1.2",
"tenant": "global",
"freshness": "2026-02-24T12:00:00Z"
},
{
"nodeId": "img-registry-io-app-v1.2",
"kind": "image",
"name": "registry.io/app:v1.2",
"imageRef": "registry.io/app:v1.2",
"registry": "registry.io",
"digest": "sha256:abc123",
"os": "linux",
"arch": "amd64",
"dependencyCount": 5,
"relationshipSummary": "contains: lodash@4.17.21",
"tenant": "global",
"freshness": "2026-02-24T12:10:00Z"
}
]

View File

@@ -0,0 +1,48 @@
[
{
"decisionId": "dec-1001",
"decisionType": "waive",
"outcomeStatus": "approved",
"subjectRef": "CVE-2025-1234",
"subjectType": "finding",
"rationale": "Reachability analysis confirms exploit path is blocked in production.",
"severity": "high",
"resolutionTimeHours": 4.5,
"contextTags": [
"production",
"waiver"
],
"similarityVector": [
0.41,
0.12,
0.33,
0.87
],
"tenant": "global",
"recordedAt": "2026-02-24T10:00:00Z",
"outcomeRecordedAt": "2026-02-24T14:30:00Z"
},
{
"decisionId": "dec-1002",
"decisionType": "remediate",
"outcomeStatus": "pending",
"subjectRef": "pkg:npm/lodash@4.17.21",
"subjectType": "package",
"rationale": "Upgrade to patched version in next rollout window.",
"severity": "critical",
"resolutionTimeHours": 0,
"contextTags": [
"staging",
"remediation"
],
"similarityVector": [
0.22,
0.44,
0.18,
0.91
],
"tenant": "global",
"recordedAt": "2026-02-24T18:00:00Z"
}
]

View File

@@ -0,0 +1,34 @@
[
{
"scanId": "scan-5001",
"imageRef": "registry.io/app:v1.2",
"scanType": "vulnerability",
"status": "complete",
"findingCount": 14,
"criticalCount": 2,
"durationMs": 4213,
"scannerVersion": "2.8.1",
"policyVerdicts": [
"fail:critical-threshold",
"warn:license"
],
"tenant": "global",
"completedAt": "2026-02-24T11:45:00Z"
},
{
"scanId": "scan-5002",
"imageRef": "registry.io/base:v3.4",
"scanType": "compliance",
"status": "complete",
"findingCount": 3,
"criticalCount": 0,
"durationMs": 1980,
"scannerVersion": "2.8.1",
"policyVerdicts": [
"pass:compliance"
],
"tenant": "tenant-b",
"completedAt": "2026-02-24T15:20:00Z"
}
]

View File

@@ -0,0 +1,33 @@
[
{
"eventId": "evt-8001",
"action": "policy.evaluate",
"actor": "admin@acme",
"module": "Policy",
"targetRef": "CVE-2025-1234",
"payloadSummary": "verdict: pass",
"tenant": "global",
"timestamp": "2026-02-24T09:12:00Z"
},
{
"eventId": "evt-8002",
"action": "scan.complete",
"actor": "scanner",
"module": "Scanner",
"targetRef": "pkg:npm/lodash@4.17.21",
"payloadSummary": "severity changed: high -> critical",
"tenant": "global",
"timestamp": "2026-02-24T11:44:00Z"
},
{
"eventId": "evt-8003",
"action": "waiver.approve",
"actor": "security.lead",
"module": "OpsMemory",
"targetRef": "decision:dec-1001",
"payloadSummary": "waiver approved with expiry",
"tenant": "global",
"timestamp": "2025-10-01T11:44:00Z"
}
]

View File

@@ -0,0 +1,43 @@
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
public sealed record SearchSynthesisRequest(
string Q,
IReadOnlyList<EntityCard> TopCards,
QueryPlan? Plan = null,
SearchSynthesisPreferences? Preferences = null);
public sealed record SearchSynthesisPreferences
{
public string Depth { get; init; } = "brief";
public int? MaxTokens { get; init; }
public bool IncludeActions { get; init; } = true;
public string Locale { get; init; } = "en";
}
public sealed record SearchSynthesisActionSuggestion(
string Label,
string Route,
string SourceEntityKey);
internal sealed record SearchSynthesisPrompt(
string PromptVersion,
string SystemPrompt,
string UserPrompt,
IReadOnlyList<EntityCard> IncludedCards,
int EstimatedTokens);
internal sealed record SearchSynthesisExecutionResult(
string DeterministicSummary,
string? LlmSummary,
double? GroundingScore,
IReadOnlyList<SearchSynthesisActionSuggestion> Actions,
string PromptVersion,
string Provider,
int TotalTokens,
bool QuotaExceeded,
bool LlmUnavailable,
string StatusCode);

View File

@@ -0,0 +1,164 @@
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using System.Text;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
internal sealed class SearchSynthesisPromptAssembler
{
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly KnowledgeSearchOptions _knowledgeOptions;
private const string PromptVersion = "search-synth-v1";
private const string FallbackSystemPrompt =
"You are the Stella Ops unified search assistant. Use only provided evidence, cite facts, and suggest concrete next actions.";
public SearchSynthesisPromptAssembler(
IOptions<UnifiedSearchOptions> unifiedOptions,
IOptions<KnowledgeSearchOptions> knowledgeOptions)
{
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
}
public SearchSynthesisPrompt Build(
string query,
IReadOnlyList<EntityCard> cards,
QueryPlan? plan,
SearchSynthesisPreferences? preferences,
string deterministicSummary)
{
var maxTokens = Math.Max(
256,
preferences?.MaxTokens ?? _unifiedOptions.Synthesis.MaxContextTokens);
var included = TrimCardsByBudget(cards, maxTokens);
var estimatedTokens = EstimateTokens(query) +
EstimateTokens(deterministicSummary) +
included.Sum(card => EstimateTokens(card.Title) + EstimateTokens(card.Snippet) + 40);
var systemPrompt = LoadSystemPrompt();
var userPrompt = BuildUserPrompt(query, included, plan, preferences, deterministicSummary);
return new SearchSynthesisPrompt(
PromptVersion,
systemPrompt,
userPrompt,
included,
estimatedTokens);
}
private IReadOnlyList<EntityCard> TrimCardsByBudget(
IReadOnlyList<EntityCard> cards,
int maxTokens)
{
var sorted = cards
.OrderByDescending(static card => card.Score)
.ThenBy(static card => card.EntityKey, StringComparer.Ordinal)
.ToArray();
var selected = new List<EntityCard>(sorted.Length);
var budget = 0;
foreach (var card in sorted)
{
var cardTokens = EstimateTokens(card.Title) + EstimateTokens(card.Snippet) + 40;
if (selected.Count > 0 && budget + cardTokens > maxTokens)
{
break;
}
selected.Add(card);
budget += cardTokens;
}
if (selected.Count == 0 && sorted.Length > 0)
{
selected.Add(sorted[0]);
}
return selected;
}
private static string BuildUserPrompt(
string query,
IReadOnlyList<EntityCard> cards,
QueryPlan? plan,
SearchSynthesisPreferences? preferences,
string deterministicSummary)
{
var locale = string.IsNullOrWhiteSpace(preferences?.Locale) ? "en" : preferences!.Locale;
var depth = string.IsNullOrWhiteSpace(preferences?.Depth) ? "brief" : preferences!.Depth;
var sb = new StringBuilder();
sb.AppendLine($"Query: \"{query}\"");
sb.AppendLine($"Intent: {plan?.Intent ?? "explore"}");
sb.AppendLine($"Depth: {depth}");
sb.AppendLine($"Locale: {locale}");
if (plan?.DetectedEntities is { Count: > 0 })
{
sb.AppendLine("Detected entities:");
foreach (var entity in plan.DetectedEntities)
{
sb.AppendLine($"- {entity.EntityType}: {entity.Value}");
}
}
sb.AppendLine();
sb.AppendLine("Evidence:");
for (var index = 0; index < cards.Count; index++)
{
var card = cards[index];
sb.AppendLine($"[{index + 1}] {card.EntityType} {card.Title} (domain={card.Domain}, score={card.Score:F3})");
sb.AppendLine($"Snippet: {card.Snippet}");
foreach (var action in card.Actions.Take(2))
{
if (!string.IsNullOrWhiteSpace(action.Route))
{
sb.AppendLine($"Action: {action.Label} -> [{card.Domain}:{action.Route}]");
}
}
}
sb.AppendLine();
sb.AppendLine("Deterministic summary:");
sb.AppendLine(deterministicSummary);
sb.AppendLine();
sb.AppendLine("Rules:");
sb.AppendLine("- Use only evidence above.");
sb.AppendLine("- Cite factual claims using [n] where n references evidence item number.");
sb.AppendLine("- Provide 2-4 concrete actions with deep links when possible.");
sb.AppendLine("- If evidence is insufficient, state that clearly.");
return sb.ToString();
}
private string LoadSystemPrompt()
{
var configured = _unifiedOptions.Synthesis.PromptPath;
if (string.IsNullOrWhiteSpace(configured))
{
return FallbackSystemPrompt;
}
var fullPath = Path.IsPathRooted(configured)
? configured
: Path.GetFullPath(Path.Combine(
string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot)
? "."
: _knowledgeOptions.RepositoryRoot,
configured));
return File.Exists(fullPath)
? File.ReadAllText(fullPath)
: FallbackSystemPrompt;
}
private static int EstimateTokens(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return 0;
}
// Simple deterministic approximation: 1 token ~= 4 chars.
return Math.Max(1, (int)Math.Ceiling(value.Length / 4d));
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
internal sealed class SearchSynthesisQuotaService
{
private readonly UnifiedSearchOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, TenantQuotaState> _states = new(StringComparer.OrdinalIgnoreCase);
public SearchSynthesisQuotaService(
IOptions<UnifiedSearchOptions> options,
TimeProvider timeProvider)
{
_options = options?.Value ?? new UnifiedSearchOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public SearchSynthesisQuotaDecision TryAcquire(string tenantId)
{
var tenant = string.IsNullOrWhiteSpace(tenantId) ? "global" : tenantId.Trim();
var state = _states.GetOrAdd(tenant, _ => new TenantQuotaState());
var now = _timeProvider.GetUtcNow();
var today = now.UtcDateTime.Date;
lock (state.Sync)
{
if (state.Day != today)
{
state.Day = today;
state.RequestsToday = 0;
}
if (state.RequestsToday >= _options.Synthesis.SynthesisRequestsPerDay)
{
return new SearchSynthesisQuotaDecision(false, "daily_limit_exceeded", null);
}
if (state.Concurrent >= _options.Synthesis.MaxConcurrentPerTenant)
{
return new SearchSynthesisQuotaDecision(false, "concurrency_limit_exceeded", null);
}
state.RequestsToday++;
state.Concurrent++;
return new SearchSynthesisQuotaDecision(
true,
"ok",
new Lease(() =>
{
lock (state.Sync)
{
state.Concurrent = Math.Max(0, state.Concurrent - 1);
}
}));
}
}
private sealed class TenantQuotaState
{
public object Sync { get; } = new();
public DateTime Day { get; set; } = DateTime.MinValue;
public int RequestsToday { get; set; }
public int Concurrent { get; set; }
}
private sealed class Lease : IDisposable
{
private readonly Action _release;
private int _released;
public Lease(Action release) => _release = release;
public void Dispose()
{
if (Interlocked.Exchange(ref _released, 1) == 0)
{
_release();
}
}
}
}
internal sealed record SearchSynthesisQuotaDecision(
bool Allowed,
string Code,
IDisposable? Lease);

View File

@@ -0,0 +1,216 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
internal sealed class SearchSynthesisService
{
private readonly SynthesisTemplateEngine _templateEngine;
private readonly LlmSynthesisEngine _llmEngine;
private readonly SearchSynthesisPromptAssembler _promptAssembler;
private readonly SearchSynthesisQuotaService _quotaService;
private readonly SearchAnalyticsService _analytics;
private readonly KnowledgeSearchOptions _knowledgeOptions;
private readonly ILogger<SearchSynthesisService> _logger;
private readonly TimeProvider _timeProvider;
public SearchSynthesisService(
SynthesisTemplateEngine templateEngine,
LlmSynthesisEngine llmEngine,
SearchSynthesisPromptAssembler promptAssembler,
SearchSynthesisQuotaService quotaService,
SearchAnalyticsService analytics,
IOptions<KnowledgeSearchOptions> knowledgeOptions,
ILogger<SearchSynthesisService> logger,
TimeProvider timeProvider)
{
_templateEngine = templateEngine ?? throw new ArgumentNullException(nameof(templateEngine));
_llmEngine = llmEngine ?? throw new ArgumentNullException(nameof(llmEngine));
_promptAssembler = promptAssembler ?? throw new ArgumentNullException(nameof(promptAssembler));
_quotaService = quotaService ?? throw new ArgumentNullException(nameof(quotaService));
_analytics = analytics ?? throw new ArgumentNullException(nameof(analytics));
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<SearchSynthesisExecutionResult> ExecuteAsync(
string tenantId,
string userId,
SearchSynthesisRequest request,
CancellationToken ct)
{
var startedAt = _timeProvider.GetUtcNow();
var query = KnowledgeSearchText.NormalizeWhitespace(request.Q);
var plan = request.Plan ?? new QueryPlan
{
OriginalQuery = query,
NormalizedQuery = query
};
var deterministic = _templateEngine.Synthesize(
query,
request.TopCards,
plan,
request.Preferences?.Locale ?? "en");
var prompt = _promptAssembler.Build(
query,
request.TopCards,
plan,
request.Preferences,
deterministic.Summary);
var quotaDecision = _quotaService.TryAcquire(tenantId);
if (!quotaDecision.Allowed)
{
await TrackAnalyticsAsync(
tenantId,
userId,
query,
request.TopCards.Count,
(int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds,
ct).ConfigureAwait(false);
return new SearchSynthesisExecutionResult(
deterministic.Summary,
null,
null,
BuildActions(request.TopCards, request.Preferences),
prompt.PromptVersion,
Provider: "none",
TotalTokens: prompt.EstimatedTokens,
QuotaExceeded: true,
LlmUnavailable: false,
StatusCode: quotaDecision.Code);
}
using var lease = quotaDecision.Lease;
if (!_knowledgeOptions.LlmSynthesisEnabled ||
string.IsNullOrWhiteSpace(_knowledgeOptions.LlmAdapterBaseUrl) ||
string.IsNullOrWhiteSpace(_knowledgeOptions.LlmProviderId))
{
await TrackAnalyticsAsync(
tenantId,
userId,
query,
request.TopCards.Count,
(int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds,
ct).ConfigureAwait(false);
return new SearchSynthesisExecutionResult(
deterministic.Summary,
null,
null,
BuildActions(request.TopCards, request.Preferences),
prompt.PromptVersion,
Provider: "template",
TotalTokens: prompt.EstimatedTokens,
QuotaExceeded: false,
LlmUnavailable: true,
StatusCode: "llm_unavailable");
}
try
{
var llmResult = await _llmEngine.SynthesizeAsync(
query,
prompt.IncludedCards,
plan.DetectedEntities,
ct).ConfigureAwait(false);
var llmSummary = llmResult?.Summary;
var groundingScore = llmResult?.GroundingScore;
var actions = BuildActions(request.TopCards, request.Preferences);
var totalTokens = prompt.EstimatedTokens + EstimateTokens(llmSummary);
var durationMs = (int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds;
await TrackAnalyticsAsync(tenantId, userId, query, request.TopCards.Count, durationMs, ct)
.ConfigureAwait(false);
return new SearchSynthesisExecutionResult(
deterministic.Summary,
llmSummary,
groundingScore,
actions,
prompt.PromptVersion,
Provider: _knowledgeOptions.LlmProviderId,
TotalTokens: totalTokens,
QuotaExceeded: false,
LlmUnavailable: llmResult is null,
StatusCode: llmResult is null ? "llm_fallback" : "complete");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Search synthesis LLM execution failed; returning deterministic result.");
var durationMs = (int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds;
await TrackAnalyticsAsync(tenantId, userId, query, request.TopCards.Count, durationMs, ct)
.ConfigureAwait(false);
return new SearchSynthesisExecutionResult(
deterministic.Summary,
null,
null,
BuildActions(request.TopCards, request.Preferences),
prompt.PromptVersion,
Provider: "template",
TotalTokens: prompt.EstimatedTokens,
QuotaExceeded: false,
LlmUnavailable: true,
StatusCode: "llm_error");
}
}
private async Task TrackAnalyticsAsync(
string tenantId,
string userId,
string query,
int sourceCount,
int durationMs,
CancellationToken ct)
{
await _analytics.RecordEventAsync(new SearchAnalyticsEvent(
TenantId: tenantId,
EventType: "synthesis",
Query: query,
UserId: userId,
ResultCount: sourceCount,
DurationMs: durationMs), ct).ConfigureAwait(false);
}
private static IReadOnlyList<SearchSynthesisActionSuggestion> BuildActions(
IReadOnlyList<EntityCard> cards,
SearchSynthesisPreferences? preferences)
{
if (preferences?.IncludeActions == false)
{
return [];
}
return cards
.OrderByDescending(static card => card.Score)
.SelectMany(static card => card.Actions.Select(action => (Card: card, Action: action)))
.Where(static item => !string.IsNullOrWhiteSpace(item.Action.Route))
.Select(static item => new SearchSynthesisActionSuggestion(
item.Action.Label,
item.Action.Route!,
item.Card.EntityKey))
.DistinctBy(static action => $"{action.Label}|{action.Route}")
.Take(4)
.ToArray();
}
private static int EstimateTokens(string? content)
{
if (string.IsNullOrWhiteSpace(content))
{
return 0;
}
return Math.Max(1, (int)Math.Ceiling(content.Length / 4d));
}
}

View File

@@ -24,12 +24,24 @@ internal sealed class UnifiedSearchIndexRefreshService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.UnifiedAutoIndexEnabled)
var liveAdaptersConfigured =
!string.IsNullOrWhiteSpace(_options.FindingsAdapterBaseUrl) ||
!string.IsNullOrWhiteSpace(_options.VexAdapterBaseUrl) ||
!string.IsNullOrWhiteSpace(_options.PolicyAdapterBaseUrl);
var autoIndexEnabled = _options.UnifiedAutoIndexEnabled || liveAdaptersConfigured;
if (!autoIndexEnabled)
{
_logger.LogDebug("Unified search auto-indexing is disabled.");
return;
}
if (!_options.UnifiedAutoIndexEnabled && liveAdaptersConfigured)
{
_logger.LogInformation(
"Unified search auto-indexing was enabled implicitly because live adapter base URLs are configured.");
}
if (_options.UnifiedAutoIndexOnStartup)
{
await SafeRebuildAsync(stoppingToken).ConfigureAwait(false);
@@ -66,7 +78,12 @@ internal sealed class UnifiedSearchIndexRefreshService : BackgroundService
{
try
{
await _indexer.IndexAllAsync(cancellationToken).ConfigureAwait(false);
var summary = await _indexer.IndexAllWithSummaryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Unified search periodic indexing run completed: domains={DomainCount}, chunks={ChunkCount}, duration_ms={DurationMs}",
summary.DomainCount,
summary.ChunkCount,
summary.DurationMs);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{

View File

@@ -27,34 +27,100 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
}
public async Task IndexAllAsync(CancellationToken cancellationToken)
{
await IndexAllWithSummaryAsync(cancellationToken).ConfigureAwait(false);
}
internal async Task<UnifiedSearchIndexSummary> IndexAllWithSummaryAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
{
_logger.LogDebug("Unified search indexing skipped because configuration is incomplete.");
return;
return new UnifiedSearchIndexSummary(0, 0, 0);
}
foreach (var adapter in _adapters)
var stopwatch = Stopwatch.StartNew();
var domains = 0;
var chunks = 0;
var changed = 0;
var removed = 0;
foreach (var domainGroup in _adapters
.GroupBy(static adapter => adapter.Domain, StringComparer.OrdinalIgnoreCase)
.OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase))
{
try
{
_logger.LogInformation("Unified search indexing domain '{Domain}'.", adapter.Domain);
var chunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (chunks.Count == 0)
var domainStopwatch = Stopwatch.StartNew();
var domain = domainGroup.Key;
var domainChunks = new List<UnifiedChunk>();
var hadSuccessfulAdapter = false;
foreach (var adapter in domainGroup)
{
try
{
_logger.LogDebug("No chunks produced by adapter for domain '{Domain}'.", adapter.Domain);
continue;
_logger.LogInformation("Unified search indexing adapter '{Adapter}' for domain '{Domain}'.",
adapter.GetType().Name,
domain);
var adapterChunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
domainChunks.AddRange(adapterChunks);
hadSuccessfulAdapter = true;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to index adapter '{Adapter}' for domain '{Domain}'; continuing with other adapters in this domain.",
adapter.GetType().Name,
domain);
}
}
await UpsertChunksAsync(chunks, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Indexed {Count} chunks for domain '{Domain}'.", chunks.Count, adapter.Domain);
}
catch (Exception ex)
if (!hadSuccessfulAdapter)
{
_logger.LogWarning(ex, "Failed to index domain '{Domain}'; continuing with other adapters.", adapter.Domain);
_logger.LogWarning(
"Unified search skipped domain '{Domain}' because all adapters failed in this refresh cycle.",
domain);
continue;
}
var deduplicated = DeduplicateChunks(domainChunks);
var changedForDomain = 0;
if (deduplicated.Count > 0)
{
changedForDomain = await UpsertChunksAsync(deduplicated, cancellationToken).ConfigureAwait(false);
}
var removedForDomain = await DeleteMissingChunksByDomainAsync(
domain,
deduplicated.Select(static chunk => chunk.ChunkId).ToArray(),
cancellationToken)
.ConfigureAwait(false);
domainStopwatch.Stop();
domains++;
chunks += deduplicated.Count;
changed += changedForDomain;
removed += removedForDomain;
_logger.LogInformation(
"Unified search refresh domain '{Domain}' completed: seen_chunks={SeenChunkCount}, changed_chunks={ChangedChunkCount}, removed={RemovedCount}, duration_ms={DurationMs}",
domain,
deduplicated.Count,
changedForDomain,
removedForDomain,
(long)domainStopwatch.Elapsed.TotalMilliseconds);
}
stopwatch.Stop();
_logger.LogInformation(
"Unified search incremental indexing completed: domains={DomainCount}, seen_chunks={SeenChunkCount}, changed_chunks={ChangedChunkCount}, removed={RemovedCount}, duration_ms={DurationMs}",
domains,
chunks,
changed,
removed,
(long)stopwatch.Elapsed.TotalMilliseconds);
return new UnifiedSearchIndexSummary(domains, chunks, (long)stopwatch.Elapsed.TotalMilliseconds);
}
public async Task<UnifiedSearchIndexSummary> RebuildAllAsync(CancellationToken cancellationToken)
@@ -69,24 +135,58 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
var domains = 0;
var chunks = 0;
foreach (var adapter in _adapters)
foreach (var domainGroup in _adapters
.GroupBy(static adapter => adapter.Domain, StringComparer.OrdinalIgnoreCase)
.OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase))
{
try
{
await DeleteChunksByDomainAsync(adapter.Domain, cancellationToken).ConfigureAwait(false);
var domainChunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
if (domainChunks.Count > 0)
{
await UpsertChunksAsync(domainChunks, cancellationToken).ConfigureAwait(false);
}
cancellationToken.ThrowIfCancellationRequested();
domains++;
chunks += domainChunks.Count;
}
catch (Exception ex)
var domain = domainGroup.Key;
var domainStopwatch = Stopwatch.StartNew();
var domainChunks = new List<UnifiedChunk>();
var hadSuccessfulAdapter = false;
foreach (var adapter in domainGroup)
{
_logger.LogWarning(ex, "Failed to rebuild domain '{Domain}'; continuing with remaining domains.", adapter.Domain);
try
{
var adapterChunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
domainChunks.AddRange(adapterChunks);
hadSuccessfulAdapter = true;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to rebuild adapter '{Adapter}' for domain '{Domain}'; continuing with other adapters in this domain.",
adapter.GetType().Name,
domain);
}
}
if (!hadSuccessfulAdapter)
{
_logger.LogWarning(
"Unified search rebuild skipped domain '{Domain}' because all adapters failed.",
domain);
continue;
}
await DeleteChunksByDomainAsync(domain, cancellationToken).ConfigureAwait(false);
var deduplicated = DeduplicateChunks(domainChunks);
if (deduplicated.Count > 0)
{
await UpsertChunksAsync(deduplicated, cancellationToken).ConfigureAwait(false);
}
domainStopwatch.Stop();
domains++;
chunks += deduplicated.Count;
_logger.LogInformation(
"Unified search rebuild domain '{Domain}' completed: chunks={ChunkCount}, duration_ms={DurationMs}",
domain,
deduplicated.Count,
(long)domainStopwatch.Elapsed.TotalMilliseconds);
}
stopwatch.Stop();
@@ -108,7 +208,42 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task UpsertChunksAsync(IReadOnlyList<UnifiedChunk> chunks, CancellationToken cancellationToken)
private async Task<int> DeleteMissingChunksByDomainAsync(
string domain,
IReadOnlyCollection<string> currentChunkIds,
CancellationToken cancellationToken)
{
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
{
return 0;
}
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
await using var command = dataSource.CreateCommand();
command.CommandTimeout = 90;
command.Parameters.AddWithValue("domain", domain);
if (currentChunkIds.Count == 0)
{
command.CommandText = "DELETE FROM advisoryai.kb_chunk WHERE domain = @domain;";
}
else
{
command.CommandText = """
DELETE FROM advisoryai.kb_chunk
WHERE domain = @domain
AND NOT (chunk_id = ANY(@chunk_ids));
""";
command.Parameters.AddWithValue(
"chunk_ids",
NpgsqlDbType.Array | NpgsqlDbType.Text,
currentChunkIds.ToArray());
}
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<int> UpsertChunksAsync(IReadOnlyList<UnifiedChunk> chunks, CancellationToken cancellationToken)
{
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
@@ -140,7 +275,12 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
NOW()
)
ON CONFLICT (chunk_id) DO UPDATE SET
doc_id = EXCLUDED.doc_id,
kind = EXCLUDED.kind,
anchor = EXCLUDED.anchor,
section_path = EXCLUDED.section_path,
span_start = EXCLUDED.span_start,
span_end = EXCLUDED.span_end,
title = EXCLUDED.title,
body = EXCLUDED.body,
body_tsv = EXCLUDED.body_tsv,
@@ -150,13 +290,29 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
entity_key = EXCLUDED.entity_key,
entity_type = EXCLUDED.entity_type,
freshness = EXCLUDED.freshness,
indexed_at = NOW();
indexed_at = NOW()
WHERE advisoryai.kb_chunk.doc_id IS DISTINCT FROM EXCLUDED.doc_id
OR advisoryai.kb_chunk.kind IS DISTINCT FROM EXCLUDED.kind
OR advisoryai.kb_chunk.anchor IS DISTINCT FROM EXCLUDED.anchor
OR advisoryai.kb_chunk.section_path IS DISTINCT FROM EXCLUDED.section_path
OR advisoryai.kb_chunk.span_start IS DISTINCT FROM EXCLUDED.span_start
OR advisoryai.kb_chunk.span_end IS DISTINCT FROM EXCLUDED.span_end
OR advisoryai.kb_chunk.title IS DISTINCT FROM EXCLUDED.title
OR advisoryai.kb_chunk.body IS DISTINCT FROM EXCLUDED.body
OR advisoryai.kb_chunk.body_tsv IS DISTINCT FROM EXCLUDED.body_tsv
OR advisoryai.kb_chunk.embedding IS DISTINCT FROM EXCLUDED.embedding
OR advisoryai.kb_chunk.metadata IS DISTINCT FROM EXCLUDED.metadata
OR advisoryai.kb_chunk.domain IS DISTINCT FROM EXCLUDED.domain
OR advisoryai.kb_chunk.entity_key IS DISTINCT FROM EXCLUDED.entity_key
OR advisoryai.kb_chunk.entity_type IS DISTINCT FROM EXCLUDED.entity_type
OR advisoryai.kb_chunk.freshness IS DISTINCT FROM EXCLUDED.freshness;
""";
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = 120;
var affectedRows = 0;
foreach (var chunk in chunks)
{
command.Parameters.Clear();
@@ -180,8 +336,10 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
command.Parameters.AddWithValue("freshness",
chunk.Freshness.HasValue ? (object)chunk.Freshness.Value : DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
affectedRows += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
return affectedRows;
}
private static async Task EnsureDocumentExistsAsync(
@@ -211,6 +369,22 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static IReadOnlyList<UnifiedChunk> DeduplicateChunks(IEnumerable<UnifiedChunk> chunks)
{
var byChunkId = new SortedDictionary<string, UnifiedChunk>(StringComparer.Ordinal);
foreach (var chunk in chunks)
{
if (string.IsNullOrWhiteSpace(chunk.ChunkId))
{
continue;
}
byChunkId[chunk.ChunkId] = chunk;
}
return byChunkId.Values.ToArray();
}
}
public sealed record UnifiedSearchIndexSummary(

View File

@@ -24,7 +24,8 @@ public sealed record UnifiedSearchRequest(
int? K = null,
UnifiedSearchFilter? Filters = null,
bool IncludeSynthesis = true,
bool IncludeDebug = false);
bool IncludeDebug = false,
AmbientContext? Ambient = null);
public sealed record UnifiedSearchFilter
{
@@ -50,6 +51,24 @@ public sealed record UnifiedSearchFilter
/// Not serialized in API responses.
/// </summary>
public IReadOnlyList<string>? UserScopes { get; init; }
/// <summary>
/// Optional user identifier for ephemeral session context carry-forward.
/// </summary>
public string? UserId { get; init; }
}
public sealed record AmbientContext
{
public string? CurrentRoute { get; init; }
public IReadOnlyList<string>? VisibleEntityKeys { get; init; }
public IReadOnlyList<string>? RecentSearches { get; init; }
public string? SessionId { get; init; }
public bool ResetSession { get; init; }
}
public sealed record SearchSuggestion(string Text, string Reason);
@@ -88,6 +107,28 @@ public sealed record EntityCard
public IReadOnlyList<string> Sources { get; init; } = [];
public EntityCardPreview? Preview { get; init; }
public IReadOnlyList<EntityCardFacet> Facets { get; init; } = [];
public IReadOnlyList<string> Connections { get; init; } = [];
public IReadOnlyDictionary<string, string> SynthesisHints { get; init; } =
new Dictionary<string, string>(StringComparer.Ordinal);
}
public sealed record EntityCardFacet
{
public string Domain { get; init; } = "knowledge";
public string Title { get; init; } = string.Empty;
public string Snippet { get; init; } = string.Empty;
public double Score { get; init; }
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public IReadOnlyList<EntityCardAction> Actions { get; init; } = [];
}
public sealed record EntityCardPreview(
@@ -138,7 +179,15 @@ public sealed record UnifiedSearchDiagnostics(
long DurationMs,
bool UsedVector,
string Mode,
QueryPlan? Plan = null);
QueryPlan? Plan = null,
IReadOnlyList<FederationBackendDiagnostic>? Federation = null);
public sealed record FederationBackendDiagnostic(
string Backend,
int ResultCount,
long DurationMs,
bool TimedOut,
string Status);
public sealed record QueryPlan
{
@@ -152,6 +201,9 @@ public sealed record QueryPlan
public IReadOnlyDictionary<string, double> DomainWeights { get; init; } =
new Dictionary<string, double>(StringComparer.Ordinal);
public IReadOnlyDictionary<string, double> ContextEntityBoosts { get; init; } =
new Dictionary<string, double>(StringComparer.Ordinal);
}
public sealed record EntityMention(

View File

@@ -0,0 +1,204 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.AdvisoryAI.UnifiedSearch;
public sealed class UnifiedSearchOptions
{
public const string SectionName = "AdvisoryAI:UnifiedSearch";
public bool Enabled { get; set; } = true;
[Range(1, 100)]
public int DefaultTopK { get; set; } = 10;
[Range(1, 4096)]
public int MaxQueryLength { get; set; } = 512;
[Range(1, 200)]
public int MaxCards { get; set; } = 20;
public Dictionary<string, double> BaseDomainWeights { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
["knowledge"] = 1.0,
["findings"] = 1.0,
["vex"] = 1.0,
["policy"] = 1.0,
["platform"] = 1.0,
["graph"] = 1.0,
["timeline"] = 1.0,
["scanner"] = 1.0,
["opsmemory"] = 1.0
};
public UnifiedSearchWeightingOptions Weighting { get; set; } = new();
public Dictionary<string, UnifiedSearchTenantFeatureFlags> TenantFeatureFlags { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public UnifiedSearchFederationOptions Federation { get; set; } = new();
public UnifiedSearchGravityBoostOptions GravityBoost { get; set; } = new();
public UnifiedSearchSynthesisOptions Synthesis { get; set; } = new();
public UnifiedSearchIngestionOptions Ingestion { get; set; } = new();
public UnifiedSearchSessionOptions Session { get; set; } = new();
}
public sealed class UnifiedSearchWeightingOptions
{
[Range(0.0, 2.0)]
public double CveBoostFindings { get; set; } = 0.35;
[Range(0.0, 2.0)]
public double CveBoostVex { get; set; } = 0.30;
[Range(0.0, 2.0)]
public double CveBoostGraph { get; set; } = 0.25;
[Range(0.0, 2.0)]
public double SecurityBoostFindings { get; set; } = 0.20;
[Range(0.0, 2.0)]
public double SecurityBoostVex { get; set; } = 0.15;
[Range(0.0, 2.0)]
public double PolicyBoostPolicy { get; set; } = 0.30;
[Range(0.0, 2.0)]
public double TroubleshootBoostKnowledge { get; set; } = 0.15;
[Range(0.0, 2.0)]
public double TroubleshootBoostOpsMemory { get; set; } = 0.10;
[Range(0.0, 2.0)]
public double PackageBoostGraph { get; set; } = 0.36;
[Range(0.0, 2.0)]
public double PackageBoostScanner { get; set; } = 0.28;
[Range(0.0, 2.0)]
public double PackageBoostFindings { get; set; } = 0.12;
[Range(0.0, 2.0)]
public double AuditBoostTimeline { get; set; } = 0.24;
[Range(0.0, 2.0)]
public double AuditBoostOpsMemory { get; set; } = 0.24;
[Range(0.0, 2.0)]
public double FilterDomainMatchBoost { get; set; } = 0.25;
[Range(0.0, 2.0)]
public double RoleScannerFindingsBoost { get; set; } = 0.15;
[Range(0.0, 2.0)]
public double RoleScannerVexBoost { get; set; } = 0.10;
[Range(0.0, 2.0)]
public double RolePolicyBoost { get; set; } = 0.20;
[Range(0.0, 2.0)]
public double RoleOpsKnowledgeBoost { get; set; } = 0.15;
[Range(0.0, 2.0)]
public double RoleOpsMemoryBoost { get; set; } = 0.10;
[Range(0.0, 2.0)]
public double RoleReleasePolicyBoost { get; set; } = 0.10;
[Range(0.0, 2.0)]
public double RoleReleaseFindingsBoost { get; set; } = 0.10;
}
public sealed class UnifiedSearchTenantFeatureFlags
{
public bool? Enabled { get; set; }
public bool? FederationEnabled { get; set; }
public bool? SynthesisEnabled { get; set; }
}
public sealed class UnifiedSearchFederationOptions
{
public bool Enabled { get; set; } = true;
public string ConsoleEndpoint { get; set; } = string.Empty;
public string GraphEndpoint { get; set; } = string.Empty;
public string TimelineEndpoint { get; set; } = string.Empty;
[Range(100, 30_000)]
public int TimeoutBudgetMs { get; set; } = 500;
[Range(1, 500)]
public int MaxFederatedResults { get; set; } = 50;
[Range(0.0, 10.0)]
public double FederationThreshold { get; set; } = 1.2;
}
public sealed class UnifiedSearchGravityBoostOptions
{
public bool Enabled { get; set; } = true;
[Range(0.0, 2.0)]
public double OneHopBoost { get; set; } = 0.30;
[Range(0.0, 2.0)]
public double TwoHopBoost { get; set; } = 0.10;
[Range(1, 200)]
public int MaxNeighborsPerEntity { get; set; } = 20;
[Range(1, 500)]
public int MaxTotalNeighbors { get; set; } = 50;
[Range(25, 5000)]
public int TimeoutMs { get; set; } = 100;
}
public sealed class UnifiedSearchSynthesisOptions
{
public bool Enabled { get; set; } = true;
[Range(256, 32_768)]
public int MaxContextTokens { get; set; } = 4000;
public string PromptPath { get; set; } = "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Synthesis/synthesis-system-prompt.txt";
[Range(1, 5000)]
public int SynthesisRequestsPerDay { get; set; } = 200;
[Range(1, 200)]
public int MaxConcurrentPerTenant { get; set; } = 10;
}
public sealed class UnifiedSearchIngestionOptions
{
public string GraphSnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/graph.snapshot.json";
public string OpsMemorySnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/opsmemory.snapshot.json";
public string TimelineSnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/timeline.snapshot.json";
public string ScannerSnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/scanner.snapshot.json";
public List<string> GraphNodeKindFilter { get; set; } = ["package", "image", "base_image", "registry"];
[Range(1, 3650)]
public int TimelineRetentionDays { get; set; } = 90;
}
public sealed class UnifiedSearchSessionOptions
{
[Range(30, 3600)]
public int InactivitySeconds { get; set; } = 300;
}

View File

@@ -2,10 +2,16 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
using StellaOps.AdvisoryAI.UnifiedSearch.Cards;
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
using StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
using StellaOps.AdvisoryAI.Vectorization;
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Linq;
namespace StellaOps.AdvisoryAI.UnifiedSearch;
@@ -13,6 +19,7 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch;
internal sealed class UnifiedSearchService : IUnifiedSearchService
{
private readonly KnowledgeSearchOptions _options;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IKnowledgeSearchStore _store;
private readonly IVectorEncoder _vectorEncoder;
private readonly QueryPlanBuilder _queryPlanBuilder;
@@ -20,9 +27,23 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
private readonly SearchAnalyticsService _analyticsService;
private readonly SearchQualityMonitor _qualityMonitor;
private readonly IEntityAliasService _entityAliasService;
private readonly FederatedSearchDispatcher? _federatedDispatcher;
private readonly GravityBoostCalculator? _gravityBoostCalculator;
private readonly AmbientContextProcessor _ambientContextProcessor;
private readonly SearchSessionContextService _searchSessionContext;
private readonly EntityCardAssembler? _entityCardAssembler;
private readonly ILogger<UnifiedSearchService> _logger;
private readonly TimeProvider _timeProvider;
private readonly IUnifiedSearchTelemetrySink? _telemetrySink;
private static readonly Regex SnippetScriptTagRegex = new(
"<script[^>]*>.*?</script>",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Singleline);
private static readonly Regex SnippetHtmlTagRegex = new(
"<[^>]+>",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SnippetWhitespaceRegex = new(
@"\s+",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
// Cached popularity map (Sprint 106 / G6)
private IReadOnlyDictionary<string, int>? _popularityMapCache;
@@ -44,10 +65,17 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
IEntityAliasService entityAliasService,
ILogger<UnifiedSearchService> logger,
TimeProvider timeProvider,
IUnifiedSearchTelemetrySink? telemetrySink = null)
IUnifiedSearchTelemetrySink? telemetrySink = null,
IOptions<UnifiedSearchOptions>? unifiedOptions = null,
FederatedSearchDispatcher? federatedDispatcher = null,
GravityBoostCalculator? gravityBoostCalculator = null,
AmbientContextProcessor? ambientContextProcessor = null,
SearchSessionContextService? searchSessionContext = null,
EntityCardAssembler? entityCardAssembler = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? new KnowledgeSearchOptions();
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
_store = store ?? throw new ArgumentNullException(nameof(store));
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
_queryPlanBuilder = queryPlanBuilder ?? throw new ArgumentNullException(nameof(queryPlanBuilder));
@@ -55,6 +83,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
_analyticsService = analyticsService ?? throw new ArgumentNullException(nameof(analyticsService));
_qualityMonitor = qualityMonitor ?? throw new ArgumentNullException(nameof(qualityMonitor));
_entityAliasService = entityAliasService ?? throw new ArgumentNullException(nameof(entityAliasService));
_federatedDispatcher = federatedDispatcher;
_gravityBoostCalculator = gravityBoostCalculator;
_ambientContextProcessor = ambientContextProcessor ?? new AmbientContextProcessor();
_searchSessionContext = searchSessionContext ?? new SearchSessionContextService();
_entityCardAssembler = entityCardAssembler;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_telemetrySink = telemetrySink;
@@ -71,12 +104,47 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return EmptyResponse(string.Empty, request.K, "empty");
}
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
if (query.Length > _unifiedOptions.MaxQueryLength)
{
return EmptyResponse(query, request.K, "query_too_long");
}
var tenantId = request.Filters?.Tenant ?? "global";
var userId = request.Filters?.UserId ?? "anonymous";
var tenantFlags = ResolveTenantFeatureFlags(tenantId);
if (!_options.Enabled || !IsSearchEnabledForTenant(tenantFlags) || string.IsNullOrWhiteSpace(_options.ConnectionString))
{
return EmptyResponse(query, request.K, "disabled");
}
var plan = _queryPlanBuilder.Build(request);
if (request.Ambient?.ResetSession == true &&
!string.IsNullOrWhiteSpace(request.Ambient.SessionId))
{
_searchSessionContext.Reset(tenantId, userId, request.Ambient.SessionId);
}
var sessionTtl = TimeSpan.FromSeconds(Math.Max(30, _unifiedOptions.Session.InactivitySeconds));
var sessionSnapshot = !string.IsNullOrWhiteSpace(request.Ambient?.SessionId)
? _searchSessionContext.GetSnapshot(
tenantId,
userId,
request.Ambient!.SessionId!,
startedAt,
sessionTtl)
: SearchSessionSnapshot.Empty;
var basePlan = _queryPlanBuilder.Build(request);
var carriedEntities = _ambientContextProcessor.CarryForwardEntities(basePlan.DetectedEntities, sessionSnapshot);
var boostedWeights = _ambientContextProcessor.ApplyRouteBoost(basePlan.DomainWeights, request.Ambient);
var contextEntityBoosts = _ambientContextProcessor.BuildEntityBoostMap(request.Ambient, sessionSnapshot);
var plan = basePlan with
{
DetectedEntities = carriedEntities,
DomainWeights = boostedWeights,
ContextEntityBoosts = contextEntityBoosts
};
var topK = ResolveTopK(request.K);
var timeout = TimeSpan.FromMilliseconds(Math.Max(250, _options.QueryTimeoutMs));
@@ -90,7 +158,21 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
timeout,
cancellationToken).ConfigureAwait(false);
var lexicalRanks = ftsRows
var federationDiagnostics = Array.Empty<FederationBackendDiagnostic>();
var federatedRows = Array.Empty<KnowledgeChunkRow>();
if (_federatedDispatcher is not null && IsFederationEnabledForTenant(tenantFlags))
{
var dispatch = await _federatedDispatcher.DispatchAsync(
query,
plan,
request.Filters,
cancellationToken).ConfigureAwait(false);
federatedRows = dispatch.Rows.ToArray();
federationDiagnostics = dispatch.Diagnostics.ToArray();
}
var lexicalRows = MergeLexicalRows(ftsRows, federatedRows);
var lexicalRanks = lexicalRows
.Select((row, index) => (row.ChunkId, Rank: index + 1, Row: row))
.ToDictionary(static item => item.ChunkId, static item => item, StringComparer.Ordinal);
@@ -135,10 +217,19 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
if (_options.PopularityBoostEnabled && _options.PopularityBoostWeight > 0d)
{
popularityMap = await GetPopularityMapAsync(
request.Filters?.Tenant ?? "global", cancellationToken).ConfigureAwait(false);
tenantId, cancellationToken).ConfigureAwait(false);
popularityWeight = _options.PopularityBoostWeight;
}
IReadOnlyDictionary<string, double>? gravityBoostMap = null;
if (_gravityBoostCalculator is not null)
{
gravityBoostMap = await _gravityBoostCalculator.BuildGravityMapAsync(
plan.DetectedEntities,
tenantId,
cancellationToken).ConfigureAwait(false);
}
var merged = WeightedRrfFusion.Fuse(
plan.DomainWeights,
lexicalRanks,
@@ -149,15 +240,36 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
_options.UnifiedFreshnessBoostEnabled,
startedAt,
popularityMap,
popularityWeight);
popularityWeight,
plan.ContextEntityBoosts,
gravityBoostMap);
var topResults = merged.Take(topK).ToArray();
var cards = topResults
var cardLimit = Math.Min(topK, Math.Max(1, _unifiedOptions.MaxCards));
var topResults = merged.Take(cardLimit).ToArray();
var flatCards = topResults
.Select(item => BuildEntityCard(item.Row, item.Score, item.Debug))
.ToArray();
IReadOnlyList<EntityCard> cards = flatCards;
if (_entityCardAssembler is not null)
{
cards = await _entityCardAssembler.AssembleAsync(flatCards, cancellationToken).ConfigureAwait(false);
}
cards = cards.Take(Math.Max(1, _unifiedOptions.MaxCards)).ToArray();
if (!string.IsNullOrWhiteSpace(request.Ambient?.SessionId))
{
_searchSessionContext.RecordQuery(
tenantId,
userId,
request.Ambient!.SessionId!,
plan.DetectedEntities,
_timeProvider.GetUtcNow());
}
SynthesisResult? synthesis = null;
if (request.IncludeSynthesis && cards.Length > 0)
if (request.IncludeSynthesis && IsSynthesisEnabledForTenant(tenantFlags) && cards.Count > 0)
{
synthesis = await _synthesisEngine.SynthesizeAsync(
query, cards, plan.DetectedEntities, cancellationToken).ConfigureAwait(false);
@@ -165,19 +277,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
// G4-003: Generate "Did you mean?" suggestions when results are sparse
IReadOnlyList<SearchSuggestion>? suggestions = null;
if (cards.Length < _options.MinFtsResultsForFuzzyFallback && _options.FuzzyFallbackEnabled)
if (cards.Count < _options.MinFtsResultsForFuzzyFallback && _options.FuzzyFallbackEnabled)
{
suggestions = await GenerateSuggestionsAsync(
query, storeFilter, cancellationToken).ConfigureAwait(false);
}
// G10-004: Generate query refinement suggestions from feedback data
var tenantId = request.Filters?.Tenant ?? "global";
IReadOnlyList<SearchRefinement>? refinements = null;
if (cards.Length < RefinementResultThreshold)
if (cards.Count < RefinementResultThreshold)
{
refinements = await GenerateRefinementsAsync(
tenantId, query, cards.Length, cancellationToken).ConfigureAwait(false);
tenantId, query, cards.Count, cancellationToken).ConfigureAwait(false);
}
var duration = _timeProvider.GetUtcNow() - startedAt;
@@ -189,11 +300,12 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
new UnifiedSearchDiagnostics(
ftsRows.Count,
vectorRows.Length,
cards.Length,
cards.Count,
(long)duration.TotalMilliseconds,
usedVector,
usedVector ? "hybrid" : "fts-only",
plan),
plan,
federationDiagnostics),
suggestions,
refinements);
@@ -211,13 +323,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
var entityKey = GetMetadataString(metadata, "entity_key") ?? BuildDefaultEntityKey(row);
var entityType = GetMetadataString(metadata, "entity_type") ?? MapKindToEntityType(row.Kind);
var severity = GetMetadataString(metadata, "severity");
var snippet = string.IsNullOrWhiteSpace(row.Snippet)
? KnowledgeSearchText.BuildSnippet(row.Body, "")
: row.Snippet;
var snippet = SanitizeSnippet(
string.IsNullOrWhiteSpace(row.Snippet)
? KnowledgeSearchText.BuildSnippet(row.Body, "")
: row.Snippet);
var actions = BuildActions(row, domain);
var sources = new List<string> { domain };
var preview = BuildPreview(row, domain);
var metadataMap = BuildCardMetadata(metadata);
metadataMap["domain"] = domain;
metadataMap["kind"] = row.Kind;
metadataMap["chunkId"] = row.ChunkId;
return new EntityCard
{
@@ -229,8 +346,27 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
Score = score,
Severity = severity,
Actions = actions,
Metadata = metadataMap,
Sources = sources,
Preview = preview
Preview = preview,
Facets =
[
new EntityCardFacet
{
Domain = domain,
Title = row.Title,
Snippet = snippet,
Score = score,
Metadata = metadataMap,
Actions = actions
}
],
SynthesisHints = new Dictionary<string, string>(StringComparer.Ordinal)
{
["entityKey"] = entityKey,
["entityType"] = entityType,
["domain"] = domain
}
};
}
@@ -469,6 +605,50 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
true));
break;
}
case "graph":
{
var nodeId = GetMetadataString(metadata, "nodeId") ?? row.Title;
actions.Add(new EntityCardAction(
"Open Graph",
"navigate",
$"/ops/graph?node={Uri.EscapeDataString(nodeId)}",
null,
true));
break;
}
case "timeline":
{
var eventId = GetMetadataString(metadata, "eventId") ?? row.Title;
actions.Add(new EntityCardAction(
"Open Event",
"navigate",
$"/ops/audit/events/{Uri.EscapeDataString(eventId)}",
null,
true));
break;
}
case "scanner":
{
var scanId = GetMetadataString(metadata, "scanId") ?? row.Title;
actions.Add(new EntityCardAction(
"Open Scan",
"navigate",
$"/console/scans/{Uri.EscapeDataString(scanId)}",
null,
true));
break;
}
case "opsmemory":
{
var decisionId = GetMetadataString(metadata, "decisionId") ?? row.Title;
actions.Add(new EntityCardAction(
"Open Decision",
"navigate",
$"/ops/opsmemory/decisions/{Uri.EscapeDataString(decisionId)}",
null,
true));
break;
}
default:
{
actions.Add(new EntityCardAction(
@@ -499,6 +679,10 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
"vex_statement" => "vex",
"policy_rule" => "policy",
"platform_entity" => "platform",
"graph_node" => "graph",
"audit_event" => "timeline",
"scan_result" => "scanner",
"ops_decision" => "opsmemory",
_ => "knowledge"
};
}
@@ -508,6 +692,41 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return $"{row.Kind}:{row.ChunkId[..Math.Min(16, row.ChunkId.Length)]}";
}
private static IReadOnlyList<KnowledgeChunkRow> MergeLexicalRows(
IReadOnlyList<KnowledgeChunkRow> primaryRows,
IReadOnlyList<KnowledgeChunkRow> federatedRows)
{
if (federatedRows.Count == 0)
{
return primaryRows;
}
var byChunk = new Dictionary<string, KnowledgeChunkRow>(StringComparer.Ordinal);
foreach (var row in primaryRows)
{
byChunk[row.ChunkId] = row;
}
foreach (var row in federatedRows)
{
if (!byChunk.TryGetValue(row.ChunkId, out var existing))
{
byChunk[row.ChunkId] = row;
continue;
}
if (row.LexicalScore > existing.LexicalScore)
{
byChunk[row.ChunkId] = row;
}
}
return byChunk.Values
.OrderByDescending(static row => row.LexicalScore)
.ThenBy(static row => row.ChunkId, StringComparer.Ordinal)
.ToArray();
}
private static string MapKindToEntityType(string kind)
{
return kind switch
@@ -517,9 +736,13 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
"doctor_check" => "doctor",
"finding" => "finding",
"vex_statement" => "vex_statement",
"policy_rule" => "policy_rule",
"platform_entity" => "platform_entity",
_ => kind
"policy_rule" => "policy_rule",
"platform_entity" => "platform_entity",
"graph_node" => "graph_node",
"audit_event" => "event",
"scan_result" => "scan",
"ops_decision" => "finding",
_ => kind
};
}
@@ -555,9 +778,21 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
case "platform":
kinds.Add("platform_entity");
break;
case "graph":
kinds.Add("graph_node");
break;
case "timeline":
kinds.Add("audit_event");
break;
case "scanner":
kinds.Add("scan_result");
break;
case "opsmemory":
kinds.Add("ops_decision");
break;
default:
throw new ArgumentException(
$"Unsupported filter domain '{domain}'. Supported values: knowledge, findings, vex, policy, platform.",
$"Unsupported filter domain '{domain}'. Supported values: knowledge, findings, vex, policy, platform, graph, timeline, scanner, opsmemory.",
nameof(unifiedFilter));
}
}
@@ -576,13 +811,19 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
"vex_statement" => "vex_statement",
"policy_rule" => "policy_rule",
"platform_entity" => "platform_entity",
"package" => "graph_node",
"image" => "graph_node",
"registry" => "graph_node",
"graph_node" => "graph_node",
"event" => "audit_event",
"scan" => "scan_result",
_ => null
};
if (kind is null)
{
throw new ArgumentException(
$"Unsupported filter entityType '{entityType}'. Supported values: docs, api, doctor, finding, vex_statement, policy_rule, platform_entity.",
$"Unsupported filter entityType '{entityType}'. Supported values: docs, api, doctor, finding, vex_statement, policy_rule, platform_entity, package, image, registry, graph_node, event, scan.",
nameof(unifiedFilter));
}
@@ -670,6 +911,48 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return value.GetString();
}
private static Dictionary<string, string> BuildCardMetadata(JsonElement metadata)
{
var map = new Dictionary<string, string>(StringComparer.Ordinal);
if (metadata.ValueKind != JsonValueKind.Object)
{
return map;
}
foreach (var property in metadata.EnumerateObject())
{
var value = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number => property.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
if (!string.IsNullOrWhiteSpace(value))
{
map[property.Name] = value!;
}
}
return map;
}
private static string SanitizeSnippet(string snippet)
{
if (string.IsNullOrWhiteSpace(snippet))
{
return string.Empty;
}
var decoded = WebUtility.HtmlDecode(snippet);
var withoutScripts = SnippetScriptTagRegex.Replace(decoded, " ");
var withoutTags = SnippetHtmlTagRegex.Replace(withoutScripts, " ");
var collapsed = SnippetWhitespaceRegex.Replace(withoutTags, " ").Trim();
return collapsed;
}
/// <summary>
/// Generates "Did you mean?" suggestions by querying the trigram fuzzy index
/// and extracting the most relevant distinct titles from the fuzzy matches.
@@ -813,12 +1096,16 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
var refinements = new List<SearchRefinement>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
const int maxRefinements = 3;
var refinementTimeoutMs = Math.Clamp(_options.QueryTimeoutMs / 6, 50, 500);
using var refinementBudgetCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
refinementBudgetCts.CancelAfter(TimeSpan.FromMilliseconds(refinementTimeoutMs));
var refinementCt = refinementBudgetCts.Token;
try
{
// 1. Check resolved alerts for similar queries
var resolvedAlerts = await _qualityMonitor.GetAlertsAsync(
tenantId, status: "resolved", limit: 50, ct: ct).ConfigureAwait(false);
tenantId, status: "resolved", limit: 50, ct: refinementCt).ConfigureAwait(false);
foreach (var alert in resolvedAlerts)
{
@@ -842,7 +1129,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
if (refinements.Count < maxRefinements)
{
var similarQueries = await _analyticsService.FindSimilarSuccessfulQueriesAsync(
tenantId, query, maxRefinements - refinements.Count, ct).ConfigureAwait(false);
tenantId, query, maxRefinements - refinements.Count, refinementCt).ConfigureAwait(false);
foreach (var similarQuery in similarQueries)
{
@@ -871,6 +1158,13 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
}
}
}
catch (OperationCanceledException) when (refinementBudgetCts.IsCancellationRequested)
{
_logger.LogDebug(
"Refinement generation timed out after {TimeoutMs}ms for query '{Query}'.",
refinementTimeoutMs,
query);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to generate query refinements for '{Query}'.", query);
@@ -912,6 +1206,49 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return trigrams;
}
private UnifiedSearchTenantFeatureFlags ResolveTenantFeatureFlags(string tenantId)
{
if (!string.IsNullOrWhiteSpace(tenantId) &&
_unifiedOptions.TenantFeatureFlags.TryGetValue(tenantId, out var tenantFlags) &&
tenantFlags is not null)
{
return tenantFlags;
}
if (_unifiedOptions.TenantFeatureFlags.TryGetValue("*", out var wildcardFlags) &&
wildcardFlags is not null)
{
return wildcardFlags;
}
return new UnifiedSearchTenantFeatureFlags();
}
private bool IsSearchEnabledForTenant(UnifiedSearchTenantFeatureFlags tenantFlags)
{
return tenantFlags.Enabled ?? _unifiedOptions.Enabled;
}
private bool IsFederationEnabledForTenant(UnifiedSearchTenantFeatureFlags tenantFlags)
{
if (!IsSearchEnabledForTenant(tenantFlags))
{
return false;
}
return tenantFlags.FederationEnabled ?? _unifiedOptions.Federation.Enabled;
}
private bool IsSynthesisEnabledForTenant(UnifiedSearchTenantFeatureFlags tenantFlags)
{
if (!IsSearchEnabledForTenant(tenantFlags))
{
return false;
}
return tenantFlags.SynthesisEnabled ?? _unifiedOptions.Synthesis.Enabled;
}
private void EmitTelemetry(QueryPlan plan, UnifiedSearchResponse response, string tenant)
{
if (_telemetrySink is null)

View File

@@ -4,7 +4,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
using StellaOps.AdvisoryAI.UnifiedSearch.Cards;
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
using StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
namespace StellaOps.AdvisoryAI.UnifiedSearch;
@@ -18,11 +22,21 @@ public static class UnifiedSearchServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddOptions<UnifiedSearchOptions>()
.Bind(configuration.GetSection(UnifiedSearchOptions.SectionName))
.ValidateDataAnnotations();
// Query understanding pipeline
services.TryAddSingleton<EntityExtractor>();
services.TryAddSingleton<IntentClassifier>();
services.TryAddSingleton<DomainWeightCalculator>();
services.TryAddSingleton<QueryPlanBuilder>();
services.TryAddSingleton<AmbientContextProcessor>();
services.TryAddSingleton<SearchSessionContextService>();
services.TryAddSingleton<IGraphNeighborProvider, HttpGraphNeighborProvider>();
services.TryAddSingleton<GravityBoostCalculator>();
services.TryAddSingleton<FederatedSearchDispatcher>();
services.TryAddSingleton<EntityCardAssembler>();
// Search analytics and history (Sprint 106 / G6)
services.TryAddSingleton<SearchAnalyticsService>();
@@ -36,15 +50,20 @@ public static class UnifiedSearchServiceCollectionExtensions
services.TryAddSingleton<CompositeSynthesisEngine>();
services.TryAddSingleton<ISynthesisEngine>(provider =>
provider.GetRequiredService<CompositeSynthesisEngine>());
services.TryAddSingleton<SearchSynthesisPromptAssembler>();
services.TryAddSingleton<SearchSynthesisQuotaService>();
services.TryAddSingleton<SearchSynthesisService>();
// Entity alias service
services.TryAddSingleton<IEntityAliasService, EntityAliasService>();
// Snapshot-based ingestion adapters (static fixture data)
services.AddSingleton<ISearchIngestionAdapter, FindingIngestionAdapter>();
services.AddSingleton<ISearchIngestionAdapter, VexStatementIngestionAdapter>();
services.AddSingleton<ISearchIngestionAdapter, PolicyRuleIngestionAdapter>();
// Snapshot-only platform catalog adapter remains static.
// Findings/VEX/Policy snapshots are now fallback paths within their live adapters.
services.AddSingleton<ISearchIngestionAdapter, PlatformCatalogIngestionAdapter>();
services.AddSingleton<ISearchIngestionAdapter, GraphNodeIngestionAdapter>();
services.AddSingleton<ISearchIngestionAdapter, OpsDecisionIngestionAdapter>();
services.AddSingleton<ISearchIngestionAdapter, TimelineEventIngestionAdapter>();
services.AddSingleton<ISearchIngestionAdapter, ScanResultIngestionAdapter>();
// Live data adapters (Sprint 103 / G2) -- call upstream microservices with snapshot fallback
services.AddSingleton<ISearchIngestionAdapter, FindingsSearchAdapter>();
@@ -55,6 +74,8 @@ public static class UnifiedSearchServiceCollectionExtensions
services.AddHttpClient("scanner-internal");
services.AddHttpClient("vex-internal");
services.AddHttpClient("policy-internal");
services.AddHttpClient("graph-internal");
services.AddHttpClient("timeline-internal");
// Named HttpClient for LLM synthesis (Sprint 104 / G3)
services.AddHttpClient("llm-synthesis");
@@ -64,6 +85,7 @@ public static class UnifiedSearchServiceCollectionExtensions
services.TryAddSingleton<IUnifiedSearchIndexer>(provider => provider.GetRequiredService<UnifiedSearchIndexer>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, UnifiedSearchIndexRefreshService>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, SearchQualityMonitorBackgroundService>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, SearchAnalyticsRetentionBackgroundService>());
// Telemetry
services.TryAddSingleton<IUnifiedSearchTelemetrySink, LoggingUnifiedSearchTelemetrySink>();

View File

@@ -19,7 +19,9 @@ internal static class WeightedRrfFusion
bool enableFreshnessBoost = false,
DateTimeOffset? referenceTime = null,
IReadOnlyDictionary<string, int>? popularityMap = null,
double popularityBoostWeight = 0.0)
double popularityBoostWeight = 0.0,
IReadOnlyDictionary<string, double>? contextEntityBoosts = null,
IReadOnlyDictionary<string, double>? gravityBoostMap = null)
{
var merged = new Dictionary<string, (KnowledgeChunkRow Row, double Score, Dictionary<string, string> Debug)>(StringComparer.Ordinal);
@@ -59,12 +61,16 @@ internal static class WeightedRrfFusion
.Select(item =>
{
var entityBoost = ComputeEntityProximityBoost(item.Row, detectedEntities);
var contextBoost = ComputeContextEntityBoost(item.Row, contextEntityBoosts);
var gravityBoost = ComputeGravityBoost(item.Row, gravityBoostMap);
var freshnessBoost = enableFreshnessBoost
? ComputeFreshnessBoost(item.Row, referenceTime ?? DateTimeOffset.UnixEpoch)
: 0d;
var popBoost = ComputePopularityBoost(item.Row, popularityMap, popularityBoostWeight);
item.Score += entityBoost + freshnessBoost + popBoost;
item.Score += entityBoost + contextBoost + gravityBoost + freshnessBoost + popBoost;
item.Debug["entityBoost"] = entityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["contextBoost"] = contextBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["gravityBoost"] = gravityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["freshnessBoost"] = freshnessBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["popularityBoost"] = popBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["chunkId"] = item.Row.ChunkId;
@@ -242,4 +248,54 @@ internal static class WeightedRrfFusion
// Logarithmic boost: log2(1 + clickCount) * weight
return Math.Log2(1 + clickCount) * popularityBoostWeight;
}
private static double ComputeContextEntityBoost(
KnowledgeChunkRow row,
IReadOnlyDictionary<string, double>? contextEntityBoosts)
{
if (contextEntityBoosts is null || contextEntityBoosts.Count == 0)
{
return 0d;
}
var entityKey = TryGetEntityKey(row);
if (string.IsNullOrWhiteSpace(entityKey))
{
return 0d;
}
return contextEntityBoosts.TryGetValue(entityKey, out var boost) ? boost : 0d;
}
private static double ComputeGravityBoost(
KnowledgeChunkRow row,
IReadOnlyDictionary<string, double>? gravityBoostMap)
{
if (gravityBoostMap is null || gravityBoostMap.Count == 0)
{
return 0d;
}
var entityKey = TryGetEntityKey(row);
if (string.IsNullOrWhiteSpace(entityKey))
{
return 0d;
}
return gravityBoostMap.TryGetValue(entityKey, out var boost) ? boost : 0d;
}
private static string? TryGetEntityKey(KnowledgeChunkRow row)
{
var metadata = row.Metadata.RootElement;
if (metadata.ValueKind != System.Text.Json.JsonValueKind.Object ||
!metadata.TryGetProperty("entity_key", out var entityKeyProp) ||
entityKeyProp.ValueKind != System.Text.Json.JsonValueKind.String)
{
return null;
}
var entityKey = entityKeyProp.GetString();
return string.IsNullOrWhiteSpace(entityKey) ? null : entityKey.Trim();
}
}

View File

@@ -1,19 +1,20 @@
// ---------------------------------------------------------------------------
// OnnxVectorEncoder — Semantic vector encoder using ONNX Runtime inference.
//
// NuGet dependency required (not yet added to .csproj):
// <PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.*" />
// NuGet dependency:
// <PackageReference Include="Microsoft.ML.OnnxRuntime" />
// Version is managed centrally in src/Directory.Packages.props.
//
// This implementation is structured for the all-MiniLM-L6-v2 sentence-transformer
// model. It performs simplified WordPiece tokenization, ONNX inference, mean-pooling,
// and L2-normalization to produce 384-dimensional embedding vectors.
//
// Until the OnnxRuntime NuGet package is installed, the encoder operates in
// "stub" mode: it falls back to a deterministic projection that preserves the
// correct 384-dim output shape and L2-normalization contract. The stub uses
// Until full runtime tensor plumbing and model assets are present, the encoder
// can run in fallback mode: it returns a deterministic projection that preserves
// the 384-dim output shape and L2-normalization contract. The fallback uses
// character n-gram hashing to produce vectors that are structurally valid but
// lack true semantic quality. When the ONNX runtime is available and the model
// file exists, true inference takes over automatically.
// lack true semantic quality. When ONNX runtime + model loading is active,
// true inference takes over automatically.
// ---------------------------------------------------------------------------
using System.Security.Cryptography;
@@ -40,6 +41,34 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
private static readonly Regex WordTokenRegex = new(
@"[\w]+|[^\s\w]",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly IReadOnlyDictionary<string, string> CanonicalTokenMap =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["deploy"] = "deploy",
["release"] = "deploy",
["promote"] = "deploy",
["promotion"] = "deploy",
["rollout"] = "deploy",
["ship"] = "deploy",
["mitigate"] = "mitigation",
["mitigation"] = "mitigation",
["remediate"] = "mitigation",
["remediation"] = "mitigation",
["fix"] = "mitigation",
["harden"] = "mitigation",
["vulnerability"] = "vulnerability",
["vulnerabilities"] = "vulnerability",
["cve"] = "vulnerability",
["ghsa"] = "vulnerability",
["policy"] = "policy",
["rule"] = "policy",
["gate"] = "policy",
["deny"] = "policy",
["allow"] = "policy",
["sbom"] = "sbom",
["bill"] = "sbom",
["materials"] = "sbom"
};
private readonly ILogger<OnnxVectorEncoder> _logger;
private readonly string _modelPath;
@@ -226,7 +255,7 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
// fall back to the deterministic character-ngram encoder.
_logger.LogDebug(
"ONNX tensor creation via reflection is not fully supported. " +
"Using deterministic fallback until Microsoft.ML.OnnxRuntime NuGet is added.");
"Using deterministic fallback until typed tensor invocation is wired for this runtime.");
return FallbackEncode(text);
}
catch (Exception ex)
@@ -323,27 +352,36 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
foreach (Match match in matches)
{
var word = match.Value;
var canonical = CanonicalizeToken(word);
// Hash the whole word into a bucket
var wordBytes = Encoding.UTF8.GetBytes(word);
var wordHash = SHA256.HashData(wordBytes);
// Distribute across multiple dimensions using different hash windows
for (var window = 0; window < 4 && window * 4 + 4 <= wordHash.Length; window++)
// Canonical token signature is the primary signal so mapped synonyms
// (e.g., deploy/release/promote) converge to nearby vectors.
var canonicalBytes = Encoding.UTF8.GetBytes(canonical);
var canonicalHash = SHA256.HashData(canonicalBytes);
for (var window = 0; window < 6 && window * 4 + 4 <= canonicalHash.Length; window++)
{
var idx = (int)(BitConverter.ToUInt32(wordHash, window * 4) % (uint)OutputDimensions);
// Use alternating signs for better distribution
vector[idx] += (window % 2 == 0) ? 1f : -0.5f;
var idx = (int)(BitConverter.ToUInt32(canonicalHash, window * 4) % (uint)OutputDimensions);
vector[idx] += (window % 2 == 0) ? 1.2f : -0.6f;
}
// Also hash character bigrams for sub-word signal
for (var c = 0; c < word.Length - 1; c++)
// Add canonical bigram signal (not raw word bigrams) to preserve
// sub-word structure while keeping synonym proximity high.
for (var c = 0; c < canonical.Length - 1; c++)
{
var bigram = word.Substring(c, 2);
var bigram = canonical.Substring(c, 2);
var bigramBytes = Encoding.UTF8.GetBytes(bigram);
var bigramHash = SHA256.HashData(bigramBytes);
var bigramIdx = (int)(BitConverter.ToUInt32(bigramHash, 0) % (uint)OutputDimensions);
vector[bigramIdx] += 0.3f;
vector[bigramIdx] += 0.4f;
}
// Keep a light lexical fingerprint for non-synonym distinctions.
if (!canonical.Equals(word, StringComparison.Ordinal))
{
var wordBytes = Encoding.UTF8.GetBytes(word);
var wordHash = SHA256.HashData(wordBytes);
var idx = (int)(BitConverter.ToUInt32(wordHash, 0) % (uint)OutputDimensions);
vector[idx] += 0.1f;
}
}
@@ -351,6 +389,18 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
return vector;
}
private static string CanonicalizeToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return string.Empty;
}
return CanonicalTokenMap.TryGetValue(token, out var canonical)
? canonical
: token;
}
// ------------------------------------------------------------------
// Mean pooling and normalization utilities
// ------------------------------------------------------------------

View File

@@ -0,0 +1 @@
placeholder: model bundle path reserved for deployment packaging; replace with licensed all-MiniLM-L6-v2 ONNX weights.

View File

@@ -2,11 +2,13 @@ using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Integration;
@@ -27,6 +29,11 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
services.RemoveAll<IUnifiedSearchIndexer>();
services.AddSingleton<IUnifiedSearchService, StubUnifiedSearchService>();
services.AddSingleton<IUnifiedSearchIndexer, StubUnifiedSearchIndexer>();
services.PostConfigure<UnifiedSearchOptions>(options =>
{
options.Synthesis.SynthesisRequestsPerDay = 1;
options.Synthesis.MaxConcurrentPerTenant = 1;
});
});
});
}
@@ -83,7 +90,7 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
Q = "cve-2024-21626",
Filters = new UnifiedSearchApiFilter
{
Domains = ["graph"]
Domains = ["unknown_domain"]
}
});
@@ -104,6 +111,30 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task OpenApi_Includes_UnifiedSearch_Contracts()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/openapi/v1.json");
if (response.StatusCode == HttpStatusCode.NotFound)
{
response = await client.GetAsync("/swagger/v1/swagger.json");
}
response.StatusCode.Should().Be(HttpStatusCode.OK);
var json = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(json);
document.RootElement.GetProperty("paths")
.TryGetProperty("/v1/search/query", out _)
.Should().BeTrue("OpenAPI must expose POST /v1/search/query");
document.RootElement.GetProperty("paths")
.TryGetProperty("/v1/search/synthesize", out _)
.Should().BeTrue("OpenAPI must expose POST /v1/search/synthesize");
}
[Fact]
public async Task Rebuild_WithAdminScope_ReturnsSummary()
{
@@ -120,6 +151,57 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
payload.ChunkCount.Should().Be(17);
}
[Fact]
public async Task Synthesize_WithMissingSynthesisScope_ReturnsForbidden()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
var response = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task Synthesize_WithScope_StreamsDeterministicFirst_AndCompletes()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate search:synthesize");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
var response = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
var stream = await response.Content.ReadAsStringAsync();
stream.Should().Contain("event: synthesis_start");
stream.Should().Contain("event: llm_status");
stream.Should().Contain("event: synthesis_end");
stream.IndexOf("event: synthesis_start", StringComparison.Ordinal)
.Should().BeLessThan(stream.IndexOf("event: synthesis_end", StringComparison.Ordinal));
}
[Fact]
public async Task Synthesize_SecondRequest_ExceedsQuota_AndEmitsQuotaStatus()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate search:synthesize");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "quota-tenant");
// First request consumes the single daily synthesis slot.
var firstResponse = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
firstResponse.StatusCode.Should().Be(HttpStatusCode.OK);
// Second request should remain HTTP 200 (SSE), but emit quota_exceeded status.
var secondResponse = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
secondResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var stream = await secondResponse.Content.ReadAsStringAsync();
stream.Should().Contain("\"status\":\"quota_exceeded\"");
stream.Should().Contain("event: synthesis_end");
}
public void Dispose()
{
_factory.Dispose();
@@ -177,4 +259,40 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
DurationMs: 12));
}
}
private static UnifiedSearchSynthesizeApiRequest BuildSynthesisRequest()
{
return new UnifiedSearchSynthesizeApiRequest
{
Q = "cve remediation guidance",
TopCards =
[
new UnifiedSearchApiCard
{
EntityKey = "cve:CVE-2024-21626",
EntityType = "finding",
Domain = "findings",
Title = "CVE-2024-21626",
Snippet = "Container breakout via runc",
Score = 1.0,
Actions =
[
new UnifiedSearchApiAction
{
Label = "View Finding",
ActionType = "navigate",
Route = "/security/triage?q=CVE-2024-21626",
IsPrimary = true
}
],
Sources = ["findings"]
}
],
Preferences = new UnifiedSearchSynthesisPreferencesApi
{
Depth = "brief",
IncludeActions = true
}
};
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
@@ -10,6 +11,7 @@ using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
using StellaOps.AdvisoryAI.Vectorization;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using StellaOps.TestKit;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
@@ -259,6 +261,28 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
"different inputs should produce different vectors");
}
[Fact]
public void G1_OnnxFallbackEncoder_DeployAndRelease_HaveHighSimilarity()
{
var deploy = OnnxVectorEncoder.FallbackEncode("deploy");
var release = OnnxVectorEncoder.FallbackEncode("release");
var similarity = KnowledgeSearchText.CosineSimilarity(deploy, release);
similarity.Should().BeGreaterThan(0.5,
"semantically similar deployment terms should map close in vector space");
}
[Fact]
public void G1_OnnxFallbackEncoder_DeployAndQuantumPhysics_HaveLowSimilarity()
{
var deploy = OnnxVectorEncoder.FallbackEncode("deploy");
var unrelated = OnnxVectorEncoder.FallbackEncode("quantum physics");
var similarity = KnowledgeSearchText.CosineSimilarity(deploy, unrelated);
similarity.Should().BeLessThan(0.2,
"unrelated concepts should remain far in vector space");
}
[Fact]
public async Task G1_SearchDiagnostics_ReportsActiveEncoderType()
{
@@ -285,6 +309,36 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
"default vector encoder should be the deterministic hash encoder");
}
[Fact]
public void G1_OnnxModel_DefaultPath_IsAccessibleInOutput()
{
var modelPath = Path.Combine(AppContext.BaseDirectory, "models", "all-MiniLM-L6-v2.onnx");
File.Exists(modelPath).Should().BeTrue(
"the configured default ONNX model path should be accessible in deployment artifacts");
}
[Fact]
public void G1_OnnxEncoderSelection_MissingModelPath_FallsBackToDeterministicHashEncoder()
{
var missingModelPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.onnx");
var services = new ServiceCollection();
services.AddLogging();
services.AddOptions();
services.Configure<KnowledgeSearchOptions>(options =>
{
options.VectorEncoderType = "onnx";
options.OnnxModelPath = missingModelPath;
});
services.AddAdvisoryPipeline();
using var provider = services.BuildServiceProvider();
var encoder = provider.GetRequiredService<IVectorEncoder>();
encoder.Should().BeOfType<DeterministicHashVectorEncoder>(
"missing ONNX model files should trigger graceful fallback instead of startup failure");
}
// ────────────────────────────────────────────────────────────────
// Sprint 103 (G2) - Cross-Domain Adapters
// ────────────────────────────────────────────────────────────────
@@ -349,9 +403,20 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
}
[Fact]
public async Task G2_UnifiedSearch_AllowedDomains_AcceptKnowledgeFindingsVexPolicyPlatform()
public async Task G2_UnifiedSearch_AllowedDomains_AcceptAllConfiguredDomains()
{
var allowedDomains = new[] { "knowledge", "findings", "vex", "policy", "platform" };
var allowedDomains = new[]
{
"knowledge",
"findings",
"vex",
"policy",
"platform",
"graph",
"timeline",
"scanner",
"opsmemory"
};
foreach (var domain in allowedDomains)
{
@@ -381,12 +446,12 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
Q = "test query",
Filters = new UnifiedSearchApiFilter
{
Domains = ["graph"]
Domains = ["unsupported_domain"]
}
});
response.StatusCode.Should().Be(HttpStatusCode.BadRequest,
"'graph' is not an allowed domain for unified search");
"unknown domains should be rejected by unified search validation");
}
[Fact]
@@ -1286,6 +1351,31 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
[Fact]
public async Task G10_FeedbackEndpoint_StoresSignal_ForQualityMetrics()
{
using var client = CreateAuthenticatedClient();
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
{
Query = "docker login fails",
EntityKey = "docs:troubleshooting",
Domain = "knowledge",
Position = 1,
Signal = "helpful",
Comment = "This guidance resolved the issue."
});
response.StatusCode.Should().Be(HttpStatusCode.Created);
using var scope = _factory.Services.CreateScope();
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
var metrics = await monitor.GetMetricsAsync("test-tenant", "7d");
metrics.FeedbackScore.Should().BeGreaterThan(0,
"stored feedback should be reflected in quality metrics for the same tenant");
}
[Fact]
public async Task G10_FeedbackEndpoint_InvalidSignal_ReturnsBadRequest()
{
@@ -1358,6 +1448,137 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
"quality metrics endpoint requires admin scope");
}
[Fact]
public async Task G10_QualityMetrics_MatchesRawEventSamples()
{
var tenant = $"metrics-{Guid.NewGuid():N}";
using var writer = CreateAuthenticatedClient(tenant);
using var admin = CreateAdminClient(tenant);
var analyticsResponse = await writer.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
{
Events =
[
new SearchAnalyticsApiEvent
{
EventType = "query",
Query = "metric sample alpha",
ResultCount = 4
},
new SearchAnalyticsApiEvent
{
EventType = "zero_result",
Query = "metric sample beta",
ResultCount = 0
},
new SearchAnalyticsApiEvent
{
EventType = "query",
Query = "metric sample gamma",
ResultCount = 2
}
]
});
analyticsResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
{
Query = "metric sample alpha",
EntityKey = "docs:alpha",
Domain = "knowledge",
Position = 0,
Signal = "helpful"
})).StatusCode.Should().Be(HttpStatusCode.Created);
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
{
Query = "metric sample beta",
EntityKey = "docs:beta",
Domain = "knowledge",
Position = 1,
Signal = "not_helpful"
})).StatusCode.Should().Be(HttpStatusCode.Created);
var metricsResponse = await admin.GetAsync("/v1/advisory-ai/search/quality/metrics?period=7d");
metricsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var metrics = await metricsResponse.Content.ReadFromJsonAsync<SearchQualityMetricsDto>();
metrics.Should().NotBeNull();
metrics!.TotalSearches.Should().Be(3,
"total-search count must match query + zero_result analytics events for the same tenant");
metrics.ZeroResultRate.Should().BeApproximately(33.3, 0.2,
"zero-result rate must be computed from raw zero_result analytics events");
metrics.AvgResultCount.Should().BeApproximately(2.0, 0.2);
metrics.FeedbackScore.Should().BeApproximately(50.0, 0.2);
}
[Fact]
public async Task G10_QualityMetrics_IncludeLowQualityTopQueriesAndTrend()
{
var tenant = $"metrics-detail-{Guid.NewGuid():N}";
using var writer = CreateAuthenticatedClient(tenant);
using var admin = CreateAdminClient(tenant);
var analyticsResponse = await writer.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
{
Events =
[
new SearchAnalyticsApiEvent
{
EventType = "query",
Query = "policy gate",
ResultCount = 3
},
new SearchAnalyticsApiEvent
{
EventType = "query",
Query = "policy gate",
ResultCount = 2
},
new SearchAnalyticsApiEvent
{
EventType = "zero_result",
Query = "unknown control token",
ResultCount = 0
}
]
});
analyticsResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
{
Query = "policy gate",
EntityKey = "policy:rule-1",
Domain = "policy",
Position = 0,
Signal = "not_helpful"
})).StatusCode.Should().Be(HttpStatusCode.Created);
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
{
Query = "policy gate",
EntityKey = "policy:rule-1",
Domain = "policy",
Position = 1,
Signal = "not_helpful"
})).StatusCode.Should().Be(HttpStatusCode.Created);
var metricsResponse = await admin.GetAsync("/v1/advisory-ai/search/quality/metrics?period=30d");
metricsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var metrics = await metricsResponse.Content.ReadFromJsonAsync<SearchQualityMetricsDto>();
metrics.Should().NotBeNull();
metrics!.LowQualityResults.Should().NotBeEmpty();
metrics.TopQueries.Should().NotBeEmpty();
metrics.Trend.Should().HaveCount(30);
var expectedQueryHash = SearchAnalyticsPrivacy.HashQuery("policy gate");
metrics.TopQueries.Any(row => row.Query.Equals(expectedQueryHash, StringComparison.OrdinalIgnoreCase))
.Should().BeTrue("top-queries should include repeated query traffic");
metrics.LowQualityResults.Any(row => row.EntityKey.Equals("policy:rule-1", StringComparison.OrdinalIgnoreCase))
.Should().BeTrue("low-quality rows should include repeated negative feedback entities");
}
[Fact]
public async Task G10_AnalyticsEndpoint_ValidBatch_ReturnsNoContent()
{
@@ -1388,6 +1609,136 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task G10_Privacy_AnalyticsEventsStoreOnlyHashedQueriesAndPseudonymousUsers()
{
const string tenant = "privacy-events-tenant";
const string userId = "alice@example.com";
const string query = "show account risks for alice@example.com";
using var scope = _factory.Services.CreateScope();
var analyticsService = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
await analyticsService.RecordEventAsync(new SearchAnalyticsEvent(
TenantId: tenant,
EventType: "query",
Query: query,
UserId: userId,
ResultCount: 1));
var events = analyticsService.GetFallbackEventsSnapshot(tenant, TimeSpan.FromMinutes(1));
events.Should().NotBeEmpty();
var stored = events[^1].Event;
stored.Query.Should().Be(SearchAnalyticsPrivacy.HashQuery(query));
stored.Query.Should().NotContain("alice", "raw query text must not be stored in analytics events");
stored.UserId.Should().Be(SearchAnalyticsPrivacy.HashUserId(tenant, userId));
stored.UserId.Should().NotBe(userId, "user identifiers must be pseudonymized before persistence");
}
[Fact]
public async Task G10_Privacy_FeedbackStoresHashedQueryAndRedactedComment()
{
const string tenant = "privacy-feedback-tenant";
const string userId = "alice@example.com";
const string query = "policy exception for alice@example.com";
using var scope = _factory.Services.CreateScope();
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
await monitor.StoreFeedbackAsync(new SearchFeedbackEntry
{
TenantId = tenant,
UserId = userId,
Query = query,
EntityKey = "policy:rule-privacy",
Domain = "policy",
Position = 0,
Signal = "not_helpful",
Comment = "Contact alice@example.com with details",
});
var feedback = monitor.GetFallbackFeedbackSnapshot(tenant, TimeSpan.FromMinutes(1));
feedback.Should().NotBeEmpty();
var stored = feedback[^1].Entry;
stored.Query.Should().Be(SearchAnalyticsPrivacy.HashQuery(query));
stored.Query.Should().NotContain("alice", "feedback analytics should not persist raw query text");
stored.UserId.Should().Be(SearchAnalyticsPrivacy.HashUserId(tenant, userId));
stored.Comment.Should().BeNull("free-form comments are redacted to avoid storing potential PII");
}
[Fact]
public async Task G10_AnalyticsCollection_Overhead_IsBelowFiveMillisecondsPerEvent()
{
using var scope = _factory.Services.CreateScope();
var analyticsService = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
const int iterations = 200;
var stopwatch = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
await analyticsService.RecordEventAsync(new SearchAnalyticsEvent(
TenantId: "latency-benchmark-tenant",
EventType: "query",
Query: $"latency benchmark query {i}",
UserId: "latency-user",
ResultCount: 1));
}
stopwatch.Stop();
var averageMs = stopwatch.Elapsed.TotalMilliseconds / iterations;
averageMs.Should().BeLessThan(5.0,
"analytics collection should remain low-overhead on the request path");
}
[Fact]
public async Task G10_AnalyticsEndpoint_SynthesisEvent_IsAccepted_AndExcludedFromQualityTotals()
{
var tenant = $"metrics-synthesis-{Guid.NewGuid():N}";
using var writer = CreateAuthenticatedClient(tenant);
using var admin = CreateAdminClient(tenant);
var response = await writer.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
{
Events =
[
new SearchAnalyticsApiEvent
{
EventType = "query",
Query = "synthesis probe query",
ResultCount = 2
},
new SearchAnalyticsApiEvent
{
EventType = "synthesis",
Query = "synthesis probe query",
EntityKey = "__synthesis__",
Domain = "synthesis",
ResultCount = 2,
DurationMs = 18
},
new SearchAnalyticsApiEvent
{
EventType = "zero_result",
Query = "synthesis probe empty",
ResultCount = 0
}
]
});
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
var metricsResponse = await admin.GetAsync("/v1/advisory-ai/search/quality/metrics?period=7d");
metricsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var metrics = await metricsResponse.Content.ReadFromJsonAsync<SearchQualityMetricsDto>();
metrics.Should().NotBeNull();
metrics!.TotalSearches.Should().Be(2,
"quality totals should include query and zero_result only; synthesis should be tracked separately");
}
[Fact]
public async Task G6_AnalyticsClickEvent_IsStoredForPopularitySignals()
{
@@ -1506,10 +1857,105 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
await monitor.RefreshAlertsAsync("test-tenant");
var alerts = await monitor.GetAlertsAsync("test-tenant", status: "open", alertType: "zero_result");
alerts.Any(alert => alert.Query.Equals("nonexistent vulnerability token", StringComparison.OrdinalIgnoreCase))
var expectedQueryHash = SearchAnalyticsPrivacy.HashQuery("nonexistent vulnerability token");
alerts.Any(alert => alert.Query.Equals(expectedQueryHash, StringComparison.OrdinalIgnoreCase))
.Should().BeTrue("five repeated zero-result events should create a zero_result quality alert");
}
[Fact]
public async Task G10_NegativeFeedbackBurst_CreatesHighNegativeFeedbackAlert()
{
using var client = CreateAuthenticatedClient();
for (var i = 0; i < 3; i++)
{
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
{
Query = "policy gate unclear",
EntityKey = $"policy:rule-{i}",
Domain = "policy",
Position = i,
Signal = "not_helpful"
});
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
using var scope = _factory.Services.CreateScope();
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
await monitor.RefreshAlertsAsync("test-tenant");
var alerts = await monitor.GetAlertsAsync("test-tenant", status: "open", alertType: "high_negative_feedback");
var expectedQueryHash = SearchAnalyticsPrivacy.HashQuery("policy gate unclear");
alerts.Any(alert => alert.Query.Equals(expectedQueryHash, StringComparison.OrdinalIgnoreCase))
.Should().BeTrue("three repeated not_helpful feedback events should raise a high_negative_feedback alert");
}
[Fact]
public async Task G10_RetentionPrune_RemovesFallbackAnalyticsAndFeedbackArtifacts()
{
var tenant = $"retention-{Guid.NewGuid():N}";
const string userId = "retention-user";
using var scope = _factory.Services.CreateScope();
var analytics = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
await analytics.RecordEventAsync(new SearchAnalyticsEvent(
TenantId: tenant,
EventType: "click",
Query: "retention query",
UserId: userId,
EntityKey: "docs:retention",
Domain: "knowledge",
Position: 0));
await analytics.RecordHistoryAsync(
tenant,
userId,
"retention query",
1);
for (var i = 0; i < 3; i++)
{
await monitor.StoreFeedbackAsync(new SearchFeedbackEntry
{
TenantId = tenant,
UserId = userId,
Query = "retention query",
EntityKey = "docs:retention",
Domain = "knowledge",
Position = i,
Signal = "not_helpful"
});
}
await monitor.RefreshAlertsAsync(tenant);
var prePopularity = await analytics.GetPopularityMapAsync(tenant, 30);
prePopularity.Should().ContainKey("docs:retention");
(await analytics.GetHistoryAsync(tenant, userId)).Should().NotBeEmpty();
(await monitor.GetAlertsAsync(tenant, status: "open", alertType: "high_negative_feedback"))
.Should().NotBeEmpty();
await Task.Delay(20);
var analyticsPrune = await analytics.PruneExpiredAsync(0);
var qualityPrune = await monitor.PruneExpiredAsync(0);
analyticsPrune.EventsDeleted.Should().BeGreaterThan(0);
analyticsPrune.HistoryDeleted.Should().BeGreaterThan(0);
qualityPrune.FeedbackDeleted.Should().BeGreaterThan(0);
qualityPrune.AlertsDeleted.Should().BeGreaterThan(0);
(await analytics.GetPopularityMapAsync(tenant, 30))
.Should().BeEmpty("retention pruning should remove expired click analytics");
(await analytics.GetHistoryAsync(tenant, userId))
.Should().BeEmpty("retention pruning should remove expired search history");
(await monitor.GetAlertsAsync(tenant, status: "open", alertType: "high_negative_feedback"))
.Should().BeEmpty("retention pruning should remove expired feedback/alerts and prevent regeneration");
}
[Fact]
public async Task G10_AlertUpdateEndpoint_InvalidStatus_ReturnsBadRequest()
{
@@ -1618,6 +2064,50 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
"chunk with higher domain weight should rank first despite lower lexical rank");
}
[Fact]
public void RrfFusion_GravityBoost_ElevatesConnectedEntity()
{
using var metaCve = JsonDocument.Parse("""{"domain":"findings","entity_key":"cve:CVE-2025-1234"}""");
using var metaImage = JsonDocument.Parse("""{"domain":"graph","entity_key":"image:registry.io/app:v1"}""");
var cveRow = new KnowledgeChunkRow("chunk-cve", "doc-cve", "finding", null, null, 0, 50, "CVE row", "body", "snippet", metaCve, null, 1.0);
var imageRow = new KnowledgeChunkRow("chunk-image", "doc-image", "graph_node", null, null, 0, 50, "Image row", "body", "snippet", metaImage, null, 1.0);
var lexicalRanks = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
{
["chunk-cve"] = ("chunk-cve", 1, cveRow),
["chunk-image"] = ("chunk-image", 2, imageRow)
};
var domainWeights = new Dictionary<string, double>(StringComparer.Ordinal)
{
["findings"] = 1.0,
["graph"] = 1.0
};
var noGravity = WeightedRrfFusion.Fuse(
domainWeights,
lexicalRanks,
[],
"CVE-2025-1234",
null);
var withGravity = WeightedRrfFusion.Fuse(
domainWeights,
lexicalRanks,
[],
"CVE-2025-1234",
null,
gravityBoostMap: new Dictionary<string, double>(StringComparer.Ordinal)
{
["image:registry.io/app:v1"] = 0.5
});
noGravity[0].Row.ChunkId.Should().Be("chunk-cve");
withGravity[0].Row.ChunkId.Should().Be("chunk-image",
"gravity boost should elevate graph-connected entities for CVE-centric queries");
}
// ────────────────────────────────────────────────────────────────
// EntityExtractor tests (supports G2, G9)
// ────────────────────────────────────────────────────────────────
@@ -1665,19 +2155,23 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
// Helpers
// ────────────────────────────────────────────────────────────────
private HttpClient CreateAuthenticatedClient()
private HttpClient CreateAuthenticatedClient() => CreateAuthenticatedClient("test-tenant");
private HttpClient CreateAuthenticatedClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
return client;
}
private HttpClient CreateAdminClient()
private HttpClient CreateAdminClient() => CreateAdminClient("test-tenant");
private HttpClient CreateAdminClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:admin");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
return client;
}

View File

@@ -0,0 +1,84 @@
using FluentAssertions;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class AmbientContextProcessorTests
{
[Fact]
public void ApplyRouteBoost_boosts_matching_domain_from_route()
{
var processor = new AmbientContextProcessor();
var weights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["knowledge"] = 1.0,
["graph"] = 1.0
};
var boosted = processor.ApplyRouteBoost(weights, new AmbientContext
{
CurrentRoute = "/ops/graph/nodes/node-1"
});
boosted["graph"].Should().BeApproximately(1.10, 0.0001);
boosted["knowledge"].Should().Be(1.0);
}
[Fact]
public void BuildEntityBoostMap_merges_visible_entities_and_session_boosts()
{
var processor = new AmbientContextProcessor();
var snapshot = new SearchSessionSnapshot(
"session-1",
DateTimeOffset.UtcNow,
new Dictionary<string, double>(StringComparer.Ordinal)
{
["cve:CVE-2025-1234"] = 0.15
});
var map = processor.BuildEntityBoostMap(
new AmbientContext
{
VisibleEntityKeys = ["cve:CVE-2025-1234", "image:registry.io/app:v1"]
},
snapshot);
map["cve:CVE-2025-1234"].Should().BeApproximately(0.20, 0.0001);
map["image:registry.io/app:v1"].Should().BeApproximately(0.20, 0.0001);
}
[Fact]
public void CarryForwardEntities_adds_session_entities_for_followup_queries_without_new_entities()
{
var processor = new AmbientContextProcessor();
var snapshot = new SearchSessionSnapshot(
"session-2",
DateTimeOffset.UtcNow,
new Dictionary<string, double>(StringComparer.Ordinal)
{
["cve:CVE-2025-9999"] = 0.12
});
var carried = processor.CarryForwardEntities([], snapshot);
carried.Should().ContainSingle();
carried[0].EntityType.Should().Be("cve");
carried[0].Value.Should().Be("CVE-2025-9999");
}
[Fact]
public void ApplyRouteBoost_without_ambient_context_is_no_op()
{
var processor = new AmbientContextProcessor();
var weights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["findings"] = 1.0
};
var boosted = processor.ApplyRouteBoost(weights, ambient: null);
boosted.Should().BeEquivalentTo(weights);
}
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
internal sealed record UnifiedSearchQualityCorpus(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("generatedAtUtc")] string GeneratedAtUtc,
[property: JsonPropertyName("caseCount")] int CaseCount,
[property: JsonPropertyName("cases")] IReadOnlyList<UnifiedSearchQualityCase> Cases);
internal sealed record UnifiedSearchQualityCase(
[property: JsonPropertyName("caseId")] string CaseId,
[property: JsonPropertyName("archetype")] string Archetype,
[property: JsonPropertyName("query")] string Query,
[property: JsonPropertyName("expected")] IReadOnlyList<UnifiedSearchExpectedResult> Expected,
[property: JsonPropertyName("requiresCrossDomain")] bool RequiresCrossDomain);
internal sealed record UnifiedSearchExpectedResult(
[property: JsonPropertyName("entityKey")] string EntityKey,
[property: JsonPropertyName("domain")] string Domain,
[property: JsonPropertyName("grade")] int Grade);
internal sealed record UnifiedSearchQualityGateThresholds(
double MinPrecisionAt1,
double MinNdcgAt10,
double MinEntityCardAccuracy,
double MinCrossDomainRecall)
{
public static UnifiedSearchQualityGateThresholds Default { get; } = new(
MinPrecisionAt1: 0.80,
MinNdcgAt10: 0.70,
MinEntityCardAccuracy: 0.85,
MinCrossDomainRecall: 0.60);
}
internal sealed record UnifiedSearchQualityMetrics(
int QueryCount,
double PrecisionAt1,
double PrecisionAt3,
double PrecisionAt5,
double PrecisionAt10,
double RecallAt10,
double NdcgAt10,
double EntityCardAccuracy,
double CrossDomainRecall,
string RankingStabilityHash);
internal sealed record UnifiedSearchQualityReport(
UnifiedSearchQualityMetrics Overall,
IReadOnlyDictionary<string, UnifiedSearchQualityMetrics> ByArchetype,
bool PassedQualityGates,
UnifiedSearchQualityGateThresholds Gates,
IReadOnlyDictionary<string, double> EffectiveDefaultDomainWeights);

View File

@@ -0,0 +1,462 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
internal sealed class UnifiedSearchQualityBenchmarkRunner
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private static readonly string[] CanonicalDomains =
[
"knowledge",
"findings",
"vex",
"policy",
"graph",
"timeline",
"scanner",
"opsmemory"
];
private readonly EntityExtractor _extractor = new();
private readonly IntentClassifier _intentClassifier = new();
public UnifiedSearchQualityCorpus LoadCorpus(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var absolute = Path.GetFullPath(path);
if (!File.Exists(absolute))
{
throw new FileNotFoundException("Unified search quality corpus was not found.", absolute);
}
var json = File.ReadAllText(absolute);
var corpus = JsonSerializer.Deserialize<UnifiedSearchQualityCorpus>(json, JsonOptions)
?? throw new InvalidOperationException($"Could not deserialize benchmark corpus at '{absolute}'.");
if (corpus.Cases.Count != corpus.CaseCount)
{
throw new InvalidOperationException(
$"Corpus caseCount mismatch. Header={corpus.CaseCount}, actual={corpus.Cases.Count}.");
}
return corpus;
}
public UnifiedSearchQualityReport Run(
UnifiedSearchQualityCorpus corpus,
UnifiedSearchOptions unifiedOptions,
UnifiedSearchQualityGateThresholds? gates = null)
{
ArgumentNullException.ThrowIfNull(corpus);
ArgumentNullException.ThrowIfNull(unifiedOptions);
var gateThresholds = gates ?? UnifiedSearchQualityGateThresholds.Default;
var knowledgeOptions = Options.Create(new KnowledgeSearchOptions
{
RoleBasedBiasEnabled = true
});
var unifiedWrapped = Options.Create(unifiedOptions);
var calculator = new DomainWeightCalculator(
_extractor,
_intentClassifier,
knowledgeOptions,
unifiedWrapped);
var evaluations = new List<CaseEvaluation>(corpus.Cases.Count);
foreach (var testCase in corpus.Cases)
{
var entities = _extractor.Extract(testCase.Query);
var weights = calculator.ComputeWeights(testCase.Query, entities, null);
var candidates = BuildCandidates(testCase);
var lexical = candidates
.OrderBy(static c => c.LexicalRank)
.ThenBy(static c => c.Row.ChunkId, StringComparer.Ordinal)
.Select((candidate, index) => (candidate.Row.ChunkId, Rank: index + 1, candidate.Row))
.ToDictionary(static item => item.ChunkId, static item => item, StringComparer.Ordinal);
var vector = candidates
.OrderBy(static c => c.VectorRank)
.ThenBy(static c => c.Row.ChunkId, StringComparer.Ordinal)
.Select((candidate, index) => (candidate.Row, Rank: index + 1, Score: 1d / (index + 1)))
.ToArray();
var fused = WeightedRrfFusion.Fuse(
weights,
lexical,
vector,
testCase.Query,
filters: null,
detectedEntities: entities,
enableFreshnessBoost: false,
referenceTime: null,
popularityMap: null,
popularityBoostWeight: 0.0,
contextEntityBoosts: null,
gravityBoostMap: null);
var ranked = fused
.Take(10)
.Select(item => candidates.First(candidate => candidate.Row.ChunkId == item.Row.ChunkId))
.ToArray();
evaluations.Add(EvaluateCase(testCase, ranked));
}
var overall = BuildMetrics(evaluations);
var byArchetype = evaluations
.GroupBy(static evaluation => evaluation.Archetype, StringComparer.Ordinal)
.OrderBy(static group => group.Key, StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => BuildMetrics(group.ToList()),
StringComparer.Ordinal);
var passed = overall.PrecisionAt1 >= gateThresholds.MinPrecisionAt1 &&
overall.NdcgAt10 >= gateThresholds.MinNdcgAt10 &&
overall.EntityCardAccuracy >= gateThresholds.MinEntityCardAccuracy &&
overall.CrossDomainRecall >= gateThresholds.MinCrossDomainRecall;
var effectiveWeights = CanonicalDomains
.ToDictionary(
static domain => domain,
domain => unifiedOptions.BaseDomainWeights.TryGetValue(domain, out var value) ? value : 1.0,
StringComparer.Ordinal);
return new UnifiedSearchQualityReport(
overall,
byArchetype,
passed,
gateThresholds,
effectiveWeights);
}
public void WriteReportJson(string path, UnifiedSearchQualityReport report)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(report);
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(report, JsonOptions);
File.WriteAllText(path, json, Encoding.UTF8);
}
private static IReadOnlyList<BenchmarkCandidate> BuildCandidates(UnifiedSearchQualityCase testCase)
{
var preferredDomains = GetPreferredDomains(testCase.Archetype);
var expectedDomains = new HashSet<string>(
testCase.Expected.Select(static expected => expected.Domain),
StringComparer.OrdinalIgnoreCase);
var candidates = new List<BenchmarkCandidate>(testCase.Expected.Count + CanonicalDomains.Length);
foreach (var expected in testCase.Expected)
{
var jitter = StableJitter($"{testCase.CaseId}:{expected.EntityKey}", 3);
var lexicalRank = BaseRankForGrade(expected.Grade) + jitter;
var vectorRank = BaseRankForGrade(expected.Grade) + 1 + jitter;
if (preferredDomains.Contains(expected.Domain))
{
lexicalRank = Math.Max(2, lexicalRank - 2);
vectorRank = Math.Max(2, vectorRank - 1);
}
candidates.Add(new BenchmarkCandidate(
expected.EntityKey,
expected.Domain,
expected.Grade,
lexicalRank,
vectorRank,
BuildRow(testCase.CaseId, expected.EntityKey, expected.Domain)));
}
for (var index = 0; index < CanonicalDomains.Length; index++)
{
var domain = CanonicalDomains[index];
if (expectedDomains.Contains(domain))
{
continue;
}
var noiseEntity = $"noise:{testCase.CaseId}:{domain}";
var lexicalRank = domain.Equals("knowledge", StringComparison.OrdinalIgnoreCase) ? 4 : 5 + index;
var vectorRank = lexicalRank + 1;
candidates.Add(new BenchmarkCandidate(
noiseEntity,
domain,
Grade: 0,
lexicalRank,
vectorRank,
BuildRow(testCase.CaseId, noiseEntity, domain)));
}
return candidates;
}
private static KnowledgeChunkRow BuildRow(string caseId, string entityKey, string domain)
{
var kind = domain switch
{
"findings" => "finding",
"vex" => "vex_statement",
"policy" => "policy_rule",
"graph" => "graph_node",
"timeline" => "audit_event",
"scanner" => "scan_result",
"opsmemory" => "ops_decision",
_ => "md_section"
};
var chunkId = StableChunkId(caseId, entityKey);
var metadata = JsonDocument.Parse(
JsonSerializer.Serialize(new Dictionary<string, string>
{
["entity_key"] = entityKey,
["domain"] = domain,
["entity_type"] = kind
}));
return new KnowledgeChunkRow(
ChunkId: chunkId,
DocId: $"doc:{caseId}",
Kind: kind,
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: 0,
Title: entityKey,
Body: $"Synthetic benchmark row for {entityKey}",
Snippet: $"Synthetic benchmark row for {entityKey}",
Metadata: metadata,
Embedding: null,
LexicalScore: 1.0);
}
private static int BaseRankForGrade(int grade)
{
return grade switch
{
3 => 4,
2 => 7,
1 => 10,
_ => 12
};
}
private static IReadOnlySet<string> GetPreferredDomains(string archetype)
{
return archetype switch
{
"cve_lookup" => new HashSet<string>(["findings", "vex", "graph"], StringComparer.OrdinalIgnoreCase),
"package_image" => new HashSet<string>(["graph", "scanner", "findings"], StringComparer.OrdinalIgnoreCase),
"documentation" => new HashSet<string>(["knowledge"], StringComparer.OrdinalIgnoreCase),
"doctor_diagnostic" => new HashSet<string>(["knowledge", "opsmemory", "timeline"], StringComparer.OrdinalIgnoreCase),
"policy_search" => new HashSet<string>(["policy", "knowledge"], StringComparer.OrdinalIgnoreCase),
"audit_timeline" => new HashSet<string>(["timeline", "opsmemory"], StringComparer.OrdinalIgnoreCase),
"cross_domain" => new HashSet<string>(["findings", "vex", "graph", "knowledge"], StringComparer.OrdinalIgnoreCase),
"conversational_followup" => new HashSet<string>(["knowledge", "findings", "policy", "opsmemory"], StringComparer.OrdinalIgnoreCase),
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase)
};
}
private static CaseEvaluation EvaluateCase(
UnifiedSearchQualityCase testCase,
IReadOnlyList<BenchmarkCandidate> ranked)
{
var expectedMap = testCase.Expected
.ToDictionary(static item => item.EntityKey, static item => item, StringComparer.Ordinal);
var relevantSet = new HashSet<string>(
testCase.Expected
.Where(static item => item.Grade >= 2)
.Select(static item => item.EntityKey),
StringComparer.Ordinal);
var top10 = ranked.Take(10).ToArray();
var topKeys = top10.Select(static row => row.EntityKey).ToArray();
double PrecisionAt(int k)
{
if (k <= 0)
{
return 0d;
}
var considered = top10.Take(k);
var hits = considered.Count(item => relevantSet.Contains(item.EntityKey));
return hits / (double)k;
}
var recallAt10 = relevantSet.Count == 0
? 1d
: top10.Count(item => relevantSet.Contains(item.EntityKey)) / (double)relevantSet.Count;
var dcg = 0d;
for (var index = 0; index < top10.Length; index++)
{
var key = top10[index].EntityKey;
var grade = expectedMap.TryGetValue(key, out var expected) ? expected.Grade : 0;
if (grade <= 0)
{
continue;
}
dcg += (Math.Pow(2d, grade) - 1d) / Math.Log2(index + 2d);
}
var idealGrades = testCase.Expected
.Select(static item => item.Grade)
.OrderByDescending(static grade => grade)
.Take(10)
.ToArray();
var idcg = 0d;
for (var index = 0; index < idealGrades.Length; index++)
{
var grade = idealGrades[index];
if (grade <= 0)
{
continue;
}
idcg += (Math.Pow(2d, grade) - 1d) / Math.Log2(index + 2d);
}
var ndcgAt10 = idcg > 0d ? dcg / idcg : 1d;
var maxGrade = testCase.Expected.Count == 0
? 0
: testCase.Expected.Max(static item => item.Grade);
var topEntityAccurate = maxGrade > 0 &&
ranked.Count > 0 &&
testCase.Expected.Any(expected =>
expected.Grade == maxGrade &&
expected.EntityKey.Equals(ranked[0].EntityKey, StringComparison.Ordinal));
var crossDomainSuccess = false;
if (testCase.RequiresCrossDomain)
{
var matchedDomains = top10
.Where(item => expectedMap.ContainsKey(item.EntityKey) && expectedMap[item.EntityKey].Grade >= 2)
.Select(item => expectedMap[item.EntityKey].Domain)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
crossDomainSuccess = matchedDomains >= 2;
}
return new CaseEvaluation(
testCase.CaseId,
testCase.Archetype,
PrecisionAt(1),
PrecisionAt(3),
PrecisionAt(5),
PrecisionAt(10),
recallAt10,
ndcgAt10,
topEntityAccurate,
testCase.RequiresCrossDomain,
crossDomainSuccess,
topKeys);
}
private static UnifiedSearchQualityMetrics BuildMetrics(IReadOnlyList<CaseEvaluation> evaluations)
{
if (evaluations.Count == 0)
{
return new UnifiedSearchQualityMetrics(0, 0d, 0d, 0d, 0d, 0d, 0d, 0d, 0d, string.Empty);
}
var crossDomainCases = evaluations.Where(static item => item.RequiresCrossDomain).ToArray();
var crossDomainRecall = crossDomainCases.Length == 0
? 1d
: crossDomainCases.Count(static item => item.CrossDomainSuccess) / (double)crossDomainCases.Length;
var stabilityHash = ComputeStabilityHash(evaluations);
return new UnifiedSearchQualityMetrics(
QueryCount: evaluations.Count,
PrecisionAt1: evaluations.Average(static item => item.PrecisionAt1),
PrecisionAt3: evaluations.Average(static item => item.PrecisionAt3),
PrecisionAt5: evaluations.Average(static item => item.PrecisionAt5),
PrecisionAt10: evaluations.Average(static item => item.PrecisionAt10),
RecallAt10: evaluations.Average(static item => item.RecallAt10),
NdcgAt10: evaluations.Average(static item => item.NdcgAt10),
EntityCardAccuracy: evaluations.Count(static item => item.TopEntityAccurate) / (double)evaluations.Count,
CrossDomainRecall: crossDomainRecall,
RankingStabilityHash: stabilityHash);
}
private static string ComputeStabilityHash(IReadOnlyList<CaseEvaluation> evaluations)
{
var builder = new StringBuilder();
foreach (var evaluation in evaluations.OrderBy(static item => item.CaseId, StringComparer.Ordinal))
{
builder.Append(evaluation.CaseId);
builder.Append('|');
builder.AppendJoin(',', evaluation.TopEntityKeys);
builder.Append('\n');
}
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash);
}
private static int StableJitter(string value, int modulo)
{
if (modulo <= 0)
{
return 0;
}
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
var raw = BitConverter.ToUInt32(bytes, 0);
return (int)(raw % (uint)modulo);
}
private static string StableChunkId(string caseId, string entityKey)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{caseId}:{entityKey}"));
return Convert.ToHexString(bytes[..8]);
}
private sealed record BenchmarkCandidate(
string EntityKey,
string Domain,
int Grade,
int LexicalRank,
int VectorRank,
KnowledgeChunkRow Row);
private sealed record CaseEvaluation(
string CaseId,
string Archetype,
double PrecisionAt1,
double PrecisionAt3,
double PrecisionAt5,
double PrecisionAt10,
double RecallAt10,
double NdcgAt10,
bool TopEntityAccurate,
bool RequiresCrossDomain,
bool CrossDomainSuccess,
IReadOnlyList<string> TopEntityKeys);
}

View File

@@ -0,0 +1,169 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Cards;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class EntityCardAssemblerTests
{
[Fact]
public async Task AssembleAsync_single_domain_entity_keeps_single_card()
{
var assembler = CreateAssembler();
var input = new[]
{
CreateCard("cve:CVE-2025-1111", "finding", "findings", "CVE-2025-1111", 0.9)
};
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
cards.Should().ContainSingle();
cards[0].EntityKey.Should().Be("cve:CVE-2025-1111");
cards[0].Facets.Should().ContainSingle();
}
[Fact]
public async Task AssembleAsync_multi_domain_entity_merges_into_single_card_with_multiple_facets()
{
var assembler = CreateAssembler();
var input = new[]
{
CreateCard("cve:CVE-2025-2222", "finding", "findings", "Finding facet", 0.8),
CreateCard("cve:CVE-2025-2222", "vex_statement", "vex", "VEX facet", 0.7)
};
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
cards.Should().ContainSingle();
cards[0].Facets.Should().HaveCount(2);
cards[0].Score.Should().BeGreaterThan(0.8, "facet diversity adds a small score lift");
}
[Fact]
public async Task AssembleAsync_alias_resolved_entity_merges_ghsa_and_cve_cards()
{
var aliases = new StubAliasService(new Dictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>>(StringComparer.Ordinal)
{
["ghsa:GHSA-ABCD-1234"] = [("cve:CVE-2025-3333", "cve")]
});
var assembler = CreateAssembler(aliases);
var input = new[]
{
CreateCard("ghsa:GHSA-ABCD-1234", "finding", "findings", "GHSA advisory", 0.88),
CreateCard("cve:CVE-2025-3333", "vex_statement", "vex", "CVE statement", 0.77)
};
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
cards.Should().ContainSingle();
cards[0].EntityKey.Should().Be("cve:CVE-2025-3333");
cards[0].Facets.Should().HaveCount(2);
}
[Fact]
public async Task AssembleAsync_standalone_result_without_entity_key_remains_individual()
{
var assembler = CreateAssembler();
var input = new[]
{
CreateCard(null, "docs", "knowledge", "Standalone doc", 0.5),
CreateCard("cve:CVE-2025-4444", "finding", "findings", "Entity card", 0.9)
};
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
cards.Should().HaveCount(2);
cards.Should().Contain(card => card.Title == "Standalone doc");
cards.Should().Contain(card => card.EntityKey == "cve:CVE-2025-4444");
}
[Fact]
public async Task AssembleAsync_respects_max_cards_limit()
{
var options = new UnifiedSearchOptions { MaxCards = 1 };
var assembler = CreateAssembler(options: options);
var input = new[]
{
CreateCard("cve:CVE-2025-1", "finding", "findings", "Top card", 0.95),
CreateCard("cve:CVE-2025-2", "finding", "findings", "Second card", 0.80)
};
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
cards.Should().ContainSingle();
cards[0].Title.Should().Be("Top card");
}
private static EntityCardAssembler CreateAssembler(
IEntityAliasService? aliasService = null,
UnifiedSearchOptions? options = null)
{
return new EntityCardAssembler(
aliasService ?? new StubAliasService(new Dictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>>()),
Options.Create(options ?? new UnifiedSearchOptions { MaxCards = 20 }),
NullLogger<EntityCardAssembler>.Instance);
}
private static EntityCard CreateCard(
string? entityKey,
string entityType,
string domain,
string title,
double score)
{
var key = string.IsNullOrWhiteSpace(entityKey) ? string.Empty : entityKey;
return new EntityCard
{
EntityKey = key,
EntityType = entityType,
Domain = domain,
Title = title,
Snippet = $"{title} snippet",
Score = score,
Actions =
[
new EntityCardAction("Open", "navigate", $"/{domain}/detail", null, true)
],
Sources = [domain],
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["domain"] = domain
}
};
}
private sealed class StubAliasService : IEntityAliasService
{
private readonly IReadOnlyDictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>> _aliases;
public StubAliasService(IReadOnlyDictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>> aliases)
{
_aliases = aliases;
}
public Task<IReadOnlyList<(string EntityKey, string EntityType)>> ResolveAliasesAsync(
string alias,
CancellationToken cancellationToken)
{
if (_aliases.TryGetValue(alias, out var values))
{
return Task.FromResult(values);
}
return Task.FromResult<IReadOnlyList<(string EntityKey, string EntityType)>>([]);
}
public Task RegisterAliasAsync(
string entityKey,
string entityType,
string alias,
string source,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,263 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
using StellaOps.AdvisoryAI.Vectorization;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Text;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class FederatedSearchDispatcherTests
{
[Fact]
public async Task DispatchAsync_returns_disabled_when_federation_is_disabled()
{
var options = Options.Create(new UnifiedSearchOptions
{
Federation = new UnifiedSearchFederationOptions
{
Enabled = false
}
});
var encoder = new Mock<IVectorEncoder>();
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f });
var dispatcher = new FederatedSearchDispatcher(
options,
new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)),
encoder.Object,
NullLogger<FederatedSearchDispatcher>.Instance);
var result = await dispatcher.DispatchAsync(
"cve-2025-1234",
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
new UnifiedSearchFilter { Tenant = "tenant-a" },
CancellationToken.None);
result.Rows.Should().BeEmpty();
result.Diagnostics.Should().ContainSingle();
result.Diagnostics[0].Status.Should().Be("disabled");
}
[Fact]
public async Task DispatchAsync_queries_configured_backends_and_deduplicates_fresher_rows()
{
var options = Options.Create(new UnifiedSearchOptions
{
Federation = new UnifiedSearchFederationOptions
{
Enabled = true,
ConsoleEndpoint = "http://console.internal",
GraphEndpoint = "http://graph.internal",
FederationThreshold = 1.0,
TimeoutBudgetMs = 2000,
MaxFederatedResults = 50
}
});
var consoleJson = """
{
"items": [
{ "id": "finding-old", "title": "Old finding", "cveId": "CVE-2025-1234", "updatedAt": "2026-02-20T00:00:00Z" },
{ "id": "finding-new", "title": "New finding", "cveId": "CVE-2025-1234", "updatedAt": "2026-02-22T00:00:00Z" }
]
}
""";
var graphJson = """
[
{ "id": "node-1", "title": "Affected image", "imageRef": "registry.io/app:v2", "updatedAt": "2026-02-23T00:00:00Z" }
]
""";
var factory = new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)
{
["scanner-internal"] = CreateClient(consoleJson),
["graph-internal"] = CreateClient(graphJson),
["timeline-internal"] = CreateClient("[]")
});
var encoder = new Mock<IVectorEncoder>();
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f, 0.3f });
var dispatcher = new FederatedSearchDispatcher(
options,
factory,
encoder.Object,
NullLogger<FederatedSearchDispatcher>.Instance);
var result = await dispatcher.DispatchAsync(
"CVE-2025-1234",
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
new UnifiedSearchFilter { Tenant = "tenant-a" },
CancellationToken.None);
result.Diagnostics.Should().HaveCount(2);
result.Diagnostics.Should().Contain(static d => d.Backend == "console" && d.Status == "ok");
result.Diagnostics.Should().Contain(static d => d.Backend == "graph" && d.Status == "ok");
result.Rows.Should().HaveCount(2, "findings rows are deduplicated by domain+entity key and freshness");
result.Rows.Should().Contain(static row => row.Title == "New finding");
result.Rows.Should().NotContain(static row => row.Title == "Old finding");
result.Rows.Should().Contain(static row => row.Title == "Affected image");
}
[Fact]
public async Task DispatchAsync_returns_not_configured_when_no_endpoints_are_defined()
{
var options = Options.Create(new UnifiedSearchOptions
{
Federation = new UnifiedSearchFederationOptions
{
Enabled = true,
FederationThreshold = 1.0
}
});
var encoder = new Mock<IVectorEncoder>();
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f });
var dispatcher = new FederatedSearchDispatcher(
options,
new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)),
encoder.Object,
NullLogger<FederatedSearchDispatcher>.Instance);
var result = await dispatcher.DispatchAsync(
"CVE-2025-9999",
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
new UnifiedSearchFilter { Tenant = "tenant-a" },
CancellationToken.None);
result.Rows.Should().BeEmpty();
result.Diagnostics.Should().ContainSingle();
result.Diagnostics[0].Status.Should().Be("not_configured");
}
[Fact]
public async Task DispatchAsync_runs_backend_queries_in_parallel()
{
var options = Options.Create(new UnifiedSearchOptions
{
Federation = new UnifiedSearchFederationOptions
{
Enabled = true,
ConsoleEndpoint = "http://console.internal",
GraphEndpoint = "http://graph.internal",
FederationThreshold = 1.0,
TimeoutBudgetMs = 2000,
MaxFederatedResults = 50
}
});
var consoleJson = """[{ "id": "finding-1", "title": "finding", "cveId": "CVE-2025-1234" }]""";
var graphJson = """[{ "id": "graph-1", "title": "image", "imageRef": "registry.io/app:v1" }]""";
var factory = new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)
{
["scanner-internal"] = CreateClient(consoleJson, TimeSpan.FromMilliseconds(250)),
["graph-internal"] = CreateClient(graphJson, TimeSpan.FromMilliseconds(250)),
["timeline-internal"] = CreateClient("[]")
});
var encoder = new Mock<IVectorEncoder>();
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f, 0.3f });
var dispatcher = new FederatedSearchDispatcher(
options,
factory,
encoder.Object,
NullLogger<FederatedSearchDispatcher>.Instance);
var sw = Stopwatch.StartNew();
var result = await dispatcher.DispatchAsync(
"CVE-2025-1234",
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
new UnifiedSearchFilter { Tenant = "tenant-a" },
CancellationToken.None);
sw.Stop();
result.Diagnostics.Should().HaveCount(2);
sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(450),
"parallel dispatch should be bounded by the slowest backend rather than serial sum");
}
private static QueryPlan CreatePlan(double findingsWeight, double graphWeight)
{
return new QueryPlan
{
OriginalQuery = "query",
NormalizedQuery = "query",
Intent = "inform",
DomainWeights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["knowledge"] = 1.0,
["findings"] = findingsWeight,
["graph"] = graphWeight,
["timeline"] = 0.2
},
DetectedEntities = new[]
{
new EntityMention("CVE-2025-1234", "cve", 0, 13)
}
};
}
private static HttpClient CreateClient(string json, TimeSpan? delay = null)
{
return new HttpClient(new StubJsonHandler(json, delay), disposeHandler: true);
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly IReadOnlyDictionary<string, HttpClient> _clients;
public StubHttpClientFactory(IReadOnlyDictionary<string, HttpClient> clients)
{
_clients = clients;
}
public HttpClient CreateClient(string name)
{
if (_clients.TryGetValue(name, out var client))
{
return client;
}
return new HttpClient(new StubJsonHandler("[]"), disposeHandler: true);
}
}
private sealed class StubJsonHandler : HttpMessageHandler
{
private readonly string _json;
private readonly TimeSpan _delay;
public StubJsonHandler(string json, TimeSpan? delay = null)
{
_json = json;
_delay = delay ?? TimeSpan.Zero;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (_delay > TimeSpan.Zero)
{
await Task.Delay(_delay, cancellationToken);
}
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_json, Encoding.UTF8, "application/json")
};
return response;
}
}
}

View File

@@ -0,0 +1,135 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
using StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class GravityBoostCalculatorTests
{
[Fact]
public async Task BuildGravityMapAsync_returns_empty_when_feature_disabled()
{
var calculator = CreateCalculator(
new UnifiedSearchOptions
{
GravityBoost = new UnifiedSearchGravityBoostOptions
{
Enabled = false
}
},
(_, _, _, _, _) => Task.FromResult<IReadOnlyList<string>>(["image:registry.io/app:v1"]));
var result = await calculator.BuildGravityMapAsync(
[new EntityMention("CVE-2026-1111", "cve", 0, 13)],
tenant: "tenant-a",
CancellationToken.None);
result.Should().BeEmpty();
}
[Fact]
public async Task BuildGravityMapAsync_boosts_one_hop_neighbors_and_skips_query_mentions()
{
var calculator = CreateCalculator(
new UnifiedSearchOptions
{
GravityBoost = new UnifiedSearchGravityBoostOptions
{
Enabled = true,
OneHopBoost = 0.42,
MaxNeighborsPerEntity = 10,
MaxTotalNeighbors = 2,
TimeoutMs = 500
}
},
(entityKey, _, _, _, _) =>
{
if (entityKey == "cve:CVE-2026-2222")
{
return Task.FromResult<IReadOnlyList<string>>(
[
"purl:pkg:npm/lodash@4.17.21",
"image:registry.io/app:v2",
"registry:registry.io"
]);
}
return Task.FromResult<IReadOnlyList<string>>([]);
});
var result = await calculator.BuildGravityMapAsync(
[
new EntityMention("CVE-2026-2222", "cve", 0, 13),
new EntityMention("pkg:npm/lodash@4.17.21", "purl", 15, 22)
],
tenant: "tenant-a",
CancellationToken.None);
result.Should().HaveCount(2, "max total neighbors is capped at two");
result.Should().Contain(static pair => pair.Key == "image:registry.io/app:v2" && pair.Value == 0.42);
result.Should().Contain(static pair => pair.Key == "registry:registry.io" && pair.Value == 0.42);
result.Should().NotContainKey("purl:pkg:npm/lodash@4.17.21");
}
[Fact]
public async Task BuildGravityMapAsync_returns_empty_when_neighbor_lookup_times_out()
{
var calculator = CreateCalculator(
new UnifiedSearchOptions
{
GravityBoost = new UnifiedSearchGravityBoostOptions
{
Enabled = true,
TimeoutMs = 20
}
},
async (_, _, _, _, ct) =>
{
await Task.Delay(500, ct);
return ["image:registry.io/app:v3"];
});
var result = await calculator.BuildGravityMapAsync(
[new EntityMention("CVE-2026-3333", "cve", 0, 13)],
tenant: "tenant-a",
CancellationToken.None);
result.Should().BeEmpty();
}
private static GravityBoostCalculator CreateCalculator(
UnifiedSearchOptions options,
Func<string, string, int, TimeSpan, CancellationToken, Task<IReadOnlyList<string>>> getNeighbors)
{
return new GravityBoostCalculator(
Options.Create(options),
new DelegateGraphNeighborProvider(getNeighbors),
NullLogger<GravityBoostCalculator>.Instance);
}
private sealed class DelegateGraphNeighborProvider : IGraphNeighborProvider
{
private readonly Func<string, string, int, TimeSpan, CancellationToken, Task<IReadOnlyList<string>>> _getNeighbors;
public DelegateGraphNeighborProvider(
Func<string, string, int, TimeSpan, CancellationToken, Task<IReadOnlyList<string>>> getNeighbors)
{
_getNeighbors = getNeighbors;
}
public Task<IReadOnlyList<string>> GetOneHopNeighborsAsync(
string entityKey,
string tenant,
int limit,
TimeSpan timeout,
CancellationToken ct)
{
return _getNeighbors(entityKey, tenant, limit, timeout, ct);
}
}
}

View File

@@ -151,7 +151,25 @@ public sealed class QueryUnderstandingTests
{
var extractor = new EntityExtractor();
var classifier = new IntentClassifier();
var calculator = new DomainWeightCalculator(extractor, classifier, Options.Create(new KnowledgeSearchOptions()));
var calculator = new DomainWeightCalculator(
extractor,
classifier,
Options.Create(new KnowledgeSearchOptions()),
Options.Create(new AdvisoryAI.UnifiedSearch.UnifiedSearchOptions
{
BaseDomainWeights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["knowledge"] = 1.0,
["findings"] = 1.0,
["vex"] = 1.0,
["policy"] = 1.0,
["platform"] = 1.0,
["graph"] = 1.0,
["timeline"] = 1.0,
["scanner"] = 1.0,
["opsmemory"] = 1.0
}
}));
var entities = extractor.Extract("hello world");
var weights = calculator.ComputeWeights("hello world", entities, null);

View File

@@ -0,0 +1,81 @@
using FluentAssertions;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class SearchSessionContextServiceTests
{
[Fact]
public void RecordQuery_and_GetSnapshot_preserve_entity_context_with_decay()
{
var service = new SearchSessionContextService();
var now = DateTimeOffset.UtcNow;
service.RecordQuery(
"tenant-a",
"user-a",
"session-a",
[new EntityMention("CVE-2025-1234", "cve", 0, 13)],
now);
var snapshot = service.GetSnapshot(
"tenant-a",
"user-a",
"session-a",
now.AddMinutes(1),
TimeSpan.FromMinutes(5));
snapshot.EntityBoosts.Should().ContainKey("cve:CVE-2025-1234");
snapshot.EntityBoosts["cve:CVE-2025-1234"].Should().BeGreaterThan(0.01);
}
[Fact]
public void GetSnapshot_expires_session_after_inactivity_ttl()
{
var service = new SearchSessionContextService();
var now = DateTimeOffset.UtcNow;
service.RecordQuery(
"tenant-a",
"user-a",
"session-expire",
[new EntityMention("CVE-2025-7777", "cve", 0, 13)],
now);
var expired = service.GetSnapshot(
"tenant-a",
"user-a",
"session-expire",
now.AddMinutes(10),
TimeSpan.FromMinutes(5));
expired.Should().BeEquivalentTo(SearchSessionSnapshot.Empty);
}
[Fact]
public void Reset_clears_session_state()
{
var service = new SearchSessionContextService();
var now = DateTimeOffset.UtcNow;
service.RecordQuery(
"tenant-a",
"user-a",
"session-reset",
[new EntityMention("pkg:npm/lodash@4.17.21", "purl", 0, 22)],
now);
service.Reset("tenant-a", "user-a", "session-reset");
var snapshot = service.GetSnapshot(
"tenant-a",
"user-a",
"session-reset",
now.AddSeconds(1),
TimeSpan.FromMinutes(5));
snapshot.Should().BeEquivalentTo(SearchSessionSnapshot.Empty);
}
}

View File

@@ -0,0 +1,132 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class SearchSynthesisPromptAssemblerTests
{
[Theory]
[InlineData("CVE-2025-1234 mitigation", "inform")]
[InlineData("how to deploy scanner", "learn")]
[InlineData("policy for critical cvss", "policy")]
[InlineData("who approved waiver yesterday", "audit")]
[InlineData("scan results for registry.io/app:v2", "explore")]
public void Build_produces_structured_prompt_for_archetypal_queries(string query, string intent)
{
var assembler = CreateAssembler();
var plan = new QueryPlan
{
OriginalQuery = query,
NormalizedQuery = query,
Intent = intent
};
var prompt = assembler.Build(
query,
CreateCards(2),
plan,
new SearchSynthesisPreferences { Depth = "brief", Locale = "en" },
"Deterministic baseline summary.");
prompt.PromptVersion.Should().Be("search-synth-v1");
prompt.SystemPrompt.Should().NotBeNullOrWhiteSpace();
prompt.UserPrompt.Should().Contain($"Query: \"{query}\"");
prompt.UserPrompt.Should().Contain($"Intent: {intent}");
prompt.UserPrompt.Should().Contain("Evidence:");
prompt.UserPrompt.Should().Contain("Deterministic summary:");
}
[Fact]
public void Build_trims_low_scored_cards_when_token_budget_is_small()
{
var assembler = CreateAssembler();
var cards = CreateCards(5).ToArray();
var prompt = assembler.Build(
"large context query",
cards,
new QueryPlan { OriginalQuery = "large context query", NormalizedQuery = "large context query", Intent = "explore" },
new SearchSynthesisPreferences { MaxTokens = 80 },
"summary");
prompt.IncludedCards.Should().NotBeEmpty();
prompt.IncludedCards.Count.Should().BeLessThan(cards.Length);
}
[Fact]
public void Build_loads_system_prompt_from_external_file_when_configured()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-search-prompt-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var promptPath = Path.Combine(tempDir, "search-system-prompt.txt");
File.WriteAllText(promptPath, "Custom system prompt for search synthesis.");
var assembler = CreateAssembler(
new UnifiedSearchOptions
{
Synthesis = new UnifiedSearchSynthesisOptions
{
PromptPath = promptPath,
MaxContextTokens = 4000,
SynthesisRequestsPerDay = 200,
MaxConcurrentPerTenant = 10
}
},
new KnowledgeSearchOptions
{
RepositoryRoot = tempDir
});
var prompt = assembler.Build(
"query",
CreateCards(1),
new QueryPlan { OriginalQuery = "query", NormalizedQuery = "query", Intent = "explore" },
null,
"summary");
prompt.SystemPrompt.Should().Be("Custom system prompt for search synthesis.");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
private static SearchSynthesisPromptAssembler CreateAssembler(
UnifiedSearchOptions? unified = null,
KnowledgeSearchOptions? knowledge = null)
{
return new SearchSynthesisPromptAssembler(
Options.Create(unified ?? new UnifiedSearchOptions()),
Options.Create(knowledge ?? new KnowledgeSearchOptions
{
RepositoryRoot = "."
}));
}
private static IReadOnlyList<EntityCard> CreateCards(int count)
{
return Enumerable.Range(1, count)
.Select(index => new EntityCard
{
EntityKey = $"cve:CVE-2025-{index:0000}",
EntityType = "finding",
Domain = "findings",
Title = $"CVE-2025-{index:0000}",
Snippet = new string('x', 120),
Score = 1.0 - index * 0.05,
Actions =
[
new EntityCardAction("View Finding", "navigate", $"/security/triage?q=CVE-2025-{index:0000}", null, true)
],
Sources = ["findings"]
})
.ToArray();
}
}

View File

@@ -0,0 +1,62 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class SearchSynthesisQuotaServiceTests
{
[Fact]
public void TryAcquire_denies_after_daily_limit_is_reached()
{
var service = CreateService(
requestsPerDay: 1,
maxConcurrent: 5);
using var first = service.TryAcquire("tenant-a").Lease;
var second = service.TryAcquire("tenant-a");
second.Allowed.Should().BeFalse();
second.Code.Should().Be("daily_limit_exceeded");
}
[Fact]
public void TryAcquire_enforces_concurrent_limit_until_lease_is_released()
{
var service = CreateService(
requestsPerDay: 10,
maxConcurrent: 1);
var first = service.TryAcquire("tenant-b");
first.Allowed.Should().BeTrue();
first.Lease.Should().NotBeNull();
var blocked = service.TryAcquire("tenant-b");
blocked.Allowed.Should().BeFalse();
blocked.Code.Should().Be("concurrency_limit_exceeded");
first.Lease!.Dispose();
var afterRelease = service.TryAcquire("tenant-b");
afterRelease.Allowed.Should().BeTrue();
afterRelease.Lease?.Dispose();
}
private static SearchSynthesisQuotaService CreateService(int requestsPerDay, int maxConcurrent)
{
var options = Options.Create(new UnifiedSearchOptions
{
Synthesis = new UnifiedSearchSynthesisOptions
{
SynthesisRequestsPerDay = requestsPerDay,
MaxConcurrentPerTenant = maxConcurrent,
MaxContextTokens = 4000,
PromptPath = "none"
}
});
return new SearchSynthesisQuotaService(options, TimeProvider.System);
}
}

View File

@@ -0,0 +1,311 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
using StellaOps.AdvisoryAI.Vectorization;
using System.Text.Json;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class UnifiedSearchIngestionAdaptersTests
{
[Fact]
public async Task GraphNodeIngestionAdapter_projects_significant_nodes_and_filters_ephemeral_nodes()
{
var tempDir = CreateTempDirectory();
try
{
var snapshotPath = Path.Combine(tempDir, "graph.snapshot.json");
var payload = JsonSerializer.Serialize(new object[]
{
new
{
tenant = "tenant-a",
nodeId = "node-pkg",
kind = "package",
name = "lodash",
version = "4.17.21",
purl = "pkg:npm/lodash@4.17.21",
dependencyCount = 12,
relationshipSummary = "depends-on express"
},
new
{
tenant = "tenant-a",
nodeId = "node-image",
kind = "image",
imageRef = "registry.acme.io/app:v2",
registry = "registry.acme.io",
dependencyCount = 5,
relationshipSummary = "contained-in prod cluster"
},
new
{
tenant = "tenant-a",
nodeId = "node-ephemeral",
kind = "package",
name = "ephemeral-only",
dependencyCount = 0
}
});
await File.WriteAllTextAsync(snapshotPath, payload);
var adapter = new GraphNodeIngestionAdapter(
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
Options.Create(new UnifiedSearchOptions
{
Ingestion = new UnifiedSearchIngestionOptions
{
GraphSnapshotPath = snapshotPath,
GraphNodeKindFilter = ["package", "image", "base_image", "registry"]
}
}),
new StubVectorEncoder(),
NullLogger<GraphNodeIngestionAdapter>.Instance);
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
chunks.Should().HaveCount(2);
chunks.Should().OnlyContain(static chunk => chunk.Domain == "graph");
chunks.Select(static chunk => chunk.EntityKey).Should().Contain("purl:pkg:npm/lodash@4.17.21");
chunks.Select(static chunk => chunk.EntityKey).Should().Contain("image:registry.acme.io/app:v2");
chunks.Should().Contain(static chunk => chunk.Title.Contains("package: lodash@4.17.21", StringComparison.Ordinal));
chunks.Should().Contain(static chunk => chunk.Title.Contains("image: registry.acme.io/app:v2", StringComparison.Ordinal));
chunks.Should().OnlyContain(static chunk => chunk.Metadata.RootElement.GetProperty("route").GetString()!.StartsWith("/ops/graph?node=", StringComparison.Ordinal));
}
finally
{
TryDeleteDirectory(tempDir);
}
}
[Fact]
public async Task OpsDecisionIngestionAdapter_projects_decisions_and_preserves_similarity_vector_metadata()
{
var tempDir = CreateTempDirectory();
try
{
var snapshotPath = Path.Combine(tempDir, "opsmemory.snapshot.json");
var payload = JsonSerializer.Serialize(new object[]
{
new
{
tenant = "tenant-a",
decisionId = "dec-1",
decisionType = "waive",
outcomeStatus = "success",
subjectRef = "CVE-2026-1111",
subjectType = "finding",
rationale = "temporary production waiver",
contextTags = new[] { "production", "urgent" },
severity = "high",
resolutionTimeHours = 1.5,
similarityVector = new[] { 0.11, 0.22, 0.33 },
recordedAt = "2026-02-22T00:00:00Z",
outcomeRecordedAt = "2026-02-23T00:00:00Z"
},
new
{
tenant = "tenant-a",
decisionId = "dec-2",
decisionType = "remediate",
outcomeStatus = "pending",
subjectRef = "pkg:npm/express@4.18.0",
subjectType = "package",
rationale = "upgrade pending maintenance window",
contextTags = new[] { "staging" },
severity = "medium",
recordedAt = "2026-02-24T00:00:00Z"
}
});
await File.WriteAllTextAsync(snapshotPath, payload);
var adapter = new OpsDecisionIngestionAdapter(
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
Options.Create(new UnifiedSearchOptions
{
Ingestion = new UnifiedSearchIngestionOptions
{
OpsMemorySnapshotPath = snapshotPath
}
}),
new StubVectorEncoder(),
NullLogger<OpsDecisionIngestionAdapter>.Instance);
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
chunks.Should().HaveCount(2);
chunks.Should().OnlyContain(static chunk => chunk.Domain == "opsmemory");
chunks.Should().Contain(static chunk => chunk.EntityKey == "cve:CVE-2026-1111");
chunks.Should().Contain(static chunk => chunk.EntityKey == "purl:pkg:npm/express@4.18.0");
var first = chunks.Single(static chunk => chunk.EntityKey == "cve:CVE-2026-1111");
first.Body.Should().Contain("waive");
first.Body.Should().Contain("contextTags: production,urgent");
first.Metadata.RootElement.GetProperty("similarityVector").GetArrayLength().Should().Be(3);
first.Metadata.RootElement.GetProperty("incrementalSignals").GetArrayLength().Should().Be(2);
}
finally
{
TryDeleteDirectory(tempDir);
}
}
[Fact]
public async Task TimelineEventIngestionAdapter_applies_retention_and_extracts_entity_keys()
{
var tempDir = CreateTempDirectory();
try
{
var now = DateTimeOffset.UtcNow;
var recent = now.AddDays(-1).ToString("O", System.Globalization.CultureInfo.InvariantCulture);
var old = now.AddDays(-180).ToString("O", System.Globalization.CultureInfo.InvariantCulture);
var snapshotPath = Path.Combine(tempDir, "timeline.snapshot.json");
var payload = JsonSerializer.Serialize(new object[]
{
new
{
tenant = "tenant-a",
eventId = "evt-cve",
action = "policy.evaluate",
actor = "admin@acme",
module = "Policy",
targetRef = "CVE-2026-7777",
timestamp = recent,
payloadSummary = "verdict changed to deny"
},
new
{
tenant = "tenant-a",
eventId = "evt-pkg",
action = "scanner.complete",
actor = "scanner-bot",
module = "Scanner",
targetRef = "pkg:npm/express@4.18.0",
timestamp = recent,
payloadSummary = "scan completed"
},
new
{
tenant = "tenant-a",
eventId = "evt-old",
action = "legacy.event",
actor = "operator",
module = "Audit",
targetRef = "CVE-2020-0001",
timestamp = old,
payloadSummary = "should be pruned by retention"
}
});
await File.WriteAllTextAsync(snapshotPath, payload);
var adapter = new TimelineEventIngestionAdapter(
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
Options.Create(new UnifiedSearchOptions
{
Ingestion = new UnifiedSearchIngestionOptions
{
TimelineSnapshotPath = snapshotPath,
TimelineRetentionDays = 30
}
}),
new StubVectorEncoder(),
NullLogger<TimelineEventIngestionAdapter>.Instance);
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
chunks.Should().HaveCount(2);
chunks.Should().OnlyContain(static chunk => chunk.Domain == "timeline");
chunks.Should().Contain(static chunk => chunk.EntityKey == "cve:CVE-2026-7777" && chunk.EntityType == "finding");
chunks.Should().Contain(static chunk => chunk.EntityKey == "purl:pkg:npm/express@4.18.0" && chunk.EntityType == "package");
chunks.Should().NotContain(static chunk => chunk.ChunkId.Contains("evt-old", StringComparison.Ordinal));
}
finally
{
TryDeleteDirectory(tempDir);
}
}
[Fact]
public async Task ScanResultIngestionAdapter_projects_scan_results_with_image_alias_metadata()
{
var tempDir = CreateTempDirectory();
try
{
var snapshotPath = Path.Combine(tempDir, "scanner.snapshot.json");
var payload = JsonSerializer.Serialize(new object[]
{
new
{
tenant = "tenant-a",
scanId = "scan-4242",
imageRef = "registry.acme.io/backend:v5",
scanType = "vulnerability",
status = "complete",
findingCount = 21,
criticalCount = 2,
scannerVersion = "1.2.3",
durationMs = 5123,
policyVerdicts = new[] { "deny", "manual_review" },
completedAt = "2026-02-24T00:00:00Z"
}
});
await File.WriteAllTextAsync(snapshotPath, payload);
var adapter = new ScanResultIngestionAdapter(
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
Options.Create(new UnifiedSearchOptions
{
Ingestion = new UnifiedSearchIngestionOptions
{
ScannerSnapshotPath = snapshotPath
}
}),
new StubVectorEncoder(),
NullLogger<ScanResultIngestionAdapter>.Instance);
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
chunks.Should().ContainSingle();
var chunk = chunks[0];
chunk.Domain.Should().Be("scanner");
chunk.Kind.Should().Be("scan_result");
chunk.EntityKey.Should().Be("scan:scan-4242");
chunk.Title.Should().Contain("21 findings");
chunk.Body.Should().Contain("policyVerdicts: deny,manual_review");
chunk.Metadata.RootElement.GetProperty("entity_aliases").GetArrayLength().Should().Be(1);
chunk.Metadata.RootElement.GetProperty("entity_aliases")[0].GetString().Should().Be("image:registry.acme.io/backend:v5");
}
finally
{
TryDeleteDirectory(tempDir);
}
}
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "stellaops-adapter-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static void TryDeleteDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
private sealed class StubVectorEncoder : IVectorEncoder
{
public float[] Encode(string text) => [0.12f, 0.34f, 0.56f, 0.78f];
}
}

View File

@@ -0,0 +1,204 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
using StellaOps.AdvisoryAI.Vectorization;
using StellaOps.TestKit;
using System.Diagnostics;
using System.Text.Json;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class UnifiedSearchPerformanceEnvelopeTests
{
[Fact]
[Trait("Category", TestCategories.Performance)]
public async Task UnifiedSearch_load_profile_supports_50_concurrent_requests_within_latency_targets()
{
var service = CreateService();
const int concurrency = 50;
const int totalRequests = 300;
var latencies = new double[totalRequests];
var gate = new SemaphoreSlim(concurrency, concurrency);
var tasks = new List<Task>(totalRequests);
for (var i = 0; i < totalRequests; i++)
{
var index = i;
await gate.WaitAsync();
tasks.Add(Task.Run(async () =>
{
var stopwatch = Stopwatch.StartNew();
try
{
var response = await service.SearchAsync(
new UnifiedSearchRequest(
$"latency benchmark query {index}",
K: 10,
IncludeSynthesis: false,
Filters: new UnifiedSearchFilter { Tenant = "perf-tenant", UserId = "perf-user" }),
CancellationToken.None);
response.Cards.Should().NotBeEmpty();
}
finally
{
stopwatch.Stop();
latencies[index] = stopwatch.Elapsed.TotalMilliseconds;
gate.Release();
}
}));
}
await Task.WhenAll(tasks);
Array.Sort(latencies);
var p50 = Percentile(latencies, 0.50);
var p95 = Percentile(latencies, 0.95);
var p99 = Percentile(latencies, 0.99);
p50.Should().BeLessThan(100, "instant results p50 target is <100ms");
p95.Should().BeLessThan(500, "full results p95 target is <500ms under concurrent load");
p99.Should().BeLessThan(800, "full results p99 target is <800ms under concurrent load");
}
[Fact]
[Trait("Category", TestCategories.Performance)]
public async Task UnifiedSearch_latency_does_not_regress_more_than_10_percent_from_phase1_baseline()
{
var service = CreateService();
const int iterations = 150;
const double phase1BaselineP95Ms = 120.0;
var latencies = new double[iterations];
for (var i = 0; i < iterations; i++)
{
var stopwatch = Stopwatch.StartNew();
var response = await service.SearchAsync(
new UnifiedSearchRequest(
$"baseline regression query {i}",
K: 10,
IncludeSynthesis: false,
Filters: new UnifiedSearchFilter { Tenant = "perf-tenant", UserId = "perf-user" }),
CancellationToken.None);
stopwatch.Stop();
response.Cards.Should().NotBeEmpty();
latencies[i] = stopwatch.Elapsed.TotalMilliseconds;
}
Array.Sort(latencies);
var currentP95 = Percentile(latencies, 0.95);
currentP95.Should().BeLessThanOrEqualTo(phase1BaselineP95Ms * 1.10,
"phase-4 search additions must not regress latency by more than 10% from phase-1 baseline");
}
private static double Percentile(IReadOnlyList<double> sorted, double percentile)
{
if (sorted.Count == 0)
{
return 0d;
}
var index = (int)Math.Ceiling(sorted.Count * percentile) - 1;
index = Math.Clamp(index, 0, sorted.Count - 1);
return sorted[index];
}
private static UnifiedSearchService CreateService()
{
var options = Options.Create(new KnowledgeSearchOptions
{
Enabled = true,
ConnectionString = "Host=localhost;Database=test",
DefaultTopK = 10,
VectorDimensions = 64,
FtsCandidateCount = 40,
VectorScanLimit = 40,
VectorCandidateCount = 20,
QueryTimeoutMs = 3000
});
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
.ReturnsAsync((string query, KnowledgeSearchFilter? _, int _, TimeSpan _, CancellationToken _, string? _) =>
[
MakeRow($"chunk:{query}:1", "md_section", "Operational runbook"),
MakeRow($"chunk:{query}:2", "policy_rule", "Enforcement policy"),
MakeRow($"chunk:{query}:3", "finding", "Security finding")
]);
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var vectorEncoder = new Mock<IVectorEncoder>();
var mockEmbedding = new float[64];
mockEmbedding[0] = 0.1f;
vectorEncoder.Setup(v => v.Encode(It.IsAny<string>())).Returns(mockEmbedding);
var extractor = new EntityExtractor();
var classifier = new IntentClassifier();
var weightCalculator = new DomainWeightCalculator(
extractor,
classifier,
options,
Options.Create(new UnifiedSearchOptions()));
var planBuilder = new QueryPlanBuilder(extractor, classifier, weightCalculator);
var synthesisEngine = new SynthesisTemplateEngine();
var analyticsService = new SearchAnalyticsService(options, NullLogger<SearchAnalyticsService>.Instance);
var qualityMonitor = new SearchQualityMonitor(options, NullLogger<SearchQualityMonitor>.Instance);
var entityAliasService = new Mock<IEntityAliasService>();
entityAliasService.Setup(s => s.ResolveAliasesAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<(string EntityKey, string EntityType)>());
return new UnifiedSearchService(
options,
storeMock.Object,
vectorEncoder.Object,
planBuilder,
synthesisEngine,
analyticsService,
qualityMonitor,
entityAliasService.Object,
NullLogger<UnifiedSearchService>.Instance,
TimeProvider.System,
telemetrySink: null,
unifiedOptions: Options.Create(new UnifiedSearchOptions()));
}
private static KnowledgeChunkRow MakeRow(string chunkId, string kind, string title)
{
var metadata = JsonDocument.Parse(kind switch
{
"policy_rule" => "{\"domain\":\"policy\",\"entity_key\":\"policy:rule\"}",
"finding" => "{\"domain\":\"findings\",\"entity_key\":\"cve:CVE-2025-1201\"}",
_ => "{\"domain\":\"knowledge\",\"entity_key\":\"doc:runbook\"}"
});
return new KnowledgeChunkRow(
ChunkId: chunkId,
DocId: "doc-1",
Kind: kind,
Anchor: null,
SectionPath: null,
SpanStart: 0,
SpanEnd: 100,
Title: title,
Body: title,
Snippet: title,
Metadata: metadata,
Embedding: null,
LexicalScore: 1.0);
}
}

View File

@@ -0,0 +1,56 @@
using FluentAssertions;
using StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
using StellaOps.AdvisoryAI.UnifiedSearch;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class UnifiedSearchQualityBenchmarkFastSubsetTests
{
[Fact]
[Trait("Category", "BenchmarkFast")]
public void Fast_subset_of_50_queries_meets_quality_floor()
{
var runner = new UnifiedSearchQualityBenchmarkRunner();
var corpus = LoadCorpus(runner);
var subsetCases = corpus.Cases.Take(50).ToArray();
var subset = new UnifiedSearchQualityCorpus(
corpus.Version,
corpus.GeneratedAtUtc,
subsetCases.Length,
subsetCases);
var report = runner.Run(subset, new UnifiedSearchOptions(), UnifiedSearchQualityGateThresholds.Default);
report.Overall.QueryCount.Should().Be(50);
report.Overall.PrecisionAt1.Should().BeGreaterThanOrEqualTo(0.75);
report.Overall.NdcgAt10.Should().BeGreaterThanOrEqualTo(0.68);
report.Overall.RankingStabilityHash.Should().NotBeNullOrWhiteSpace();
}
private static UnifiedSearchQualityCorpus LoadCorpus(UnifiedSearchQualityBenchmarkRunner runner)
{
var cursor = new DirectoryInfo(AppContext.BaseDirectory);
while (cursor is not null)
{
var candidate = Path.Combine(
cursor.FullName,
"src",
"AdvisoryAI",
"__Tests",
"StellaOps.AdvisoryAI.Tests",
"TestData",
"unified-search-quality-corpus.json");
if (File.Exists(candidate))
{
return runner.LoadCorpus(candidate);
}
cursor = cursor.Parent;
}
throw new FileNotFoundException(
"Could not locate unified-search-quality-corpus.json from test base directory.",
"src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TestData/unified-search-quality-corpus.json");
}
}

View File

@@ -0,0 +1,298 @@
using FluentAssertions;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class UnifiedSearchQualityBenchmarkTests
{
private static readonly Lazy<UnifiedSearchQualityCorpus> Corpus = new(LoadCorpus);
private static readonly UnifiedSearchQualityGateThresholds Gates = UnifiedSearchQualityGateThresholds.Default;
[Fact]
[Trait("Category", TestCategories.Benchmark)]
public void Quality_corpus_contains_200_plus_queries_with_relevance_grades()
{
var corpus = Corpus.Value;
corpus.Cases.Count.Should().BeGreaterThanOrEqualTo(200);
corpus.Cases.Should().OnlyContain(c => c.Expected.Count > 0);
corpus.Cases.SelectMany(static c => c.Expected)
.Should().OnlyContain(expected => expected.Grade >= 0 && expected.Grade <= 3);
}
[Fact]
[Trait("Category", TestCategories.Benchmark)]
public void Benchmark_runner_computes_metrics_and_enforces_quality_gates()
{
var runner = new UnifiedSearchQualityBenchmarkRunner();
var report = runner.Run(Corpus.Value, new UnifiedSearchOptions(), Gates);
Console.WriteLine(
$"UnifiedSearch tuned defaults -> P@1={report.Overall.PrecisionAt1:F4}, " +
$"NDCG@10={report.Overall.NdcgAt10:F4}, EntityAcc={report.Overall.EntityCardAccuracy:F4}, " +
$"CrossDomain={report.Overall.CrossDomainRecall:F4}, Hash={report.Overall.RankingStabilityHash}");
report.Overall.QueryCount.Should().BeGreaterThanOrEqualTo(200);
report.Overall.PrecisionAt1.Should().BeGreaterThanOrEqualTo(Gates.MinPrecisionAt1);
report.Overall.NdcgAt10.Should().BeGreaterThanOrEqualTo(Gates.MinNdcgAt10);
report.Overall.EntityCardAccuracy.Should().BeGreaterThanOrEqualTo(Gates.MinEntityCardAccuracy);
report.Overall.CrossDomainRecall.Should().BeGreaterThanOrEqualTo(Gates.MinCrossDomainRecall);
report.PassedQualityGates.Should().BeTrue();
var outPath = Path.Combine(Path.GetTempPath(), "unified-search-quality-report.json");
runner.WriteReportJson(outPath, report);
File.Exists(outPath).Should().BeTrue();
var saved = File.ReadAllText(outPath);
saved.Should().Contain("\"PassedQualityGates\"");
saved.Should().Contain("\"PrecisionAt1\"");
saved.Should().Contain("\"RankingStabilityHash\"");
}
[Fact]
[Trait("Category", TestCategories.Determinism)]
public void Benchmark_runner_produces_stable_ranking_hash_across_runs()
{
var runner = new UnifiedSearchQualityBenchmarkRunner();
var run1 = runner.Run(Corpus.Value, new UnifiedSearchOptions(), Gates);
var run2 = runner.Run(Corpus.Value, new UnifiedSearchOptions(), Gates);
run1.Overall.RankingStabilityHash.Should().Be(run2.Overall.RankingStabilityHash);
run1.Overall.PrecisionAt1.Should().Be(run2.Overall.PrecisionAt1);
run1.Overall.NdcgAt10.Should().Be(run2.Overall.NdcgAt10);
}
[Fact]
[Trait("Category", TestCategories.Benchmark)]
public void Grid_search_tuning_improves_baseline_and_is_deterministic()
{
var runner = new UnifiedSearchQualityBenchmarkRunner();
var corpus = Corpus.Value;
var baselineOptions = BuildBaselineOptions();
var baseline = runner.Run(corpus, baselineOptions, Gates);
var best = FindBestConfiguration(runner, corpus);
var bestSecondPass = FindBestConfiguration(runner, corpus);
best.Report.Overall.NdcgAt10.Should().BeGreaterThan(baseline.Overall.NdcgAt10);
best.Report.Overall.PrecisionAt1.Should().BeGreaterThan(baseline.Overall.PrecisionAt1);
best.Report.PassedQualityGates.Should().BeTrue();
best.Configuration.Should().BeEquivalentTo(bestSecondPass.Configuration);
best.Report.Overall.RankingStabilityHash.Should().Be(bestSecondPass.Report.Overall.RankingStabilityHash);
// Validate defaults are aligned with tuned parameters captured in code.
var defaultReport = runner.Run(corpus, new UnifiedSearchOptions(), Gates);
defaultReport.Overall.NdcgAt10.Should().BeGreaterThanOrEqualTo(best.Report.Overall.NdcgAt10 - 0.01);
defaultReport.Overall.PrecisionAt1.Should().BeGreaterThanOrEqualTo(best.Report.Overall.PrecisionAt1 - 0.01);
Console.WriteLine(
$"UnifiedSearch baseline -> P@1={baseline.Overall.PrecisionAt1:F4}, " +
$"NDCG@10={baseline.Overall.NdcgAt10:F4}, EntityAcc={baseline.Overall.EntityCardAccuracy:F4}, " +
$"CrossDomain={baseline.Overall.CrossDomainRecall:F4}, Hash={baseline.Overall.RankingStabilityHash}");
Console.WriteLine(
$"UnifiedSearch tuned-best -> P@1={best.Report.Overall.PrecisionAt1:F4}, " +
$"NDCG@10={best.Report.Overall.NdcgAt10:F4}, EntityAcc={best.Report.Overall.EntityCardAccuracy:F4}, " +
$"CrossDomain={best.Report.Overall.CrossDomainRecall:F4}, Hash={best.Report.Overall.RankingStabilityHash}");
Console.WriteLine(
$"UnifiedSearch tuned-defaults -> P@1={defaultReport.Overall.PrecisionAt1:F4}, " +
$"NDCG@10={defaultReport.Overall.NdcgAt10:F4}, EntityAcc={defaultReport.Overall.EntityCardAccuracy:F4}, " +
$"CrossDomain={defaultReport.Overall.CrossDomainRecall:F4}, Hash={defaultReport.Overall.RankingStabilityHash}");
}
private static (UnifiedSearchWeightingOptions Configuration, UnifiedSearchQualityReport Report) FindBestConfiguration(
UnifiedSearchQualityBenchmarkRunner runner,
UnifiedSearchQualityCorpus corpus)
{
var cveFindings = new[] { 0.35, 0.45 };
var cveVex = new[] { 0.30, 0.38 };
var packageGraph = new[] { 0.20, 0.36, 0.48 };
var packageScanner = new[] { 0.12, 0.28, 0.40 };
var auditTimeline = new[] { 0.10, 0.24, 0.34 };
var policyBoost = new[] { 0.30, 0.38 };
UnifiedSearchWeightingOptions? bestConfig = null;
UnifiedSearchQualityReport? bestReport = null;
foreach (var findingBoost in cveFindings)
{
foreach (var vexBoost in cveVex)
{
foreach (var graphBoost in packageGraph)
{
foreach (var scannerBoost in packageScanner)
{
foreach (var timelineBoost in auditTimeline)
{
foreach (var policy in policyBoost)
{
var options = new UnifiedSearchOptions
{
BaseDomainWeights = BuildDefaultBaseWeights(),
Weighting = new UnifiedSearchWeightingOptions
{
CveBoostFindings = findingBoost,
CveBoostVex = vexBoost,
CveBoostGraph = 0.30,
SecurityBoostFindings = 0.24,
SecurityBoostVex = 0.18,
PolicyBoostPolicy = policy,
TroubleshootBoostKnowledge = 0.20,
TroubleshootBoostOpsMemory = 0.14,
PackageBoostGraph = graphBoost,
PackageBoostScanner = scannerBoost,
PackageBoostFindings = 0.12,
AuditBoostTimeline = timelineBoost,
AuditBoostOpsMemory = 0.24,
FilterDomainMatchBoost = 0.25,
RoleScannerFindingsBoost = 0.18,
RoleScannerVexBoost = 0.12,
RolePolicyBoost = 0.24,
RoleOpsKnowledgeBoost = 0.18,
RoleOpsMemoryBoost = 0.12,
RoleReleasePolicyBoost = 0.12,
RoleReleaseFindingsBoost = 0.12
}
};
var report = runner.Run(corpus, options, Gates);
if (bestReport is null || IsBetter(report, bestReport))
{
bestReport = report;
bestConfig = options.Weighting;
}
}
}
}
}
}
}
bestConfig.Should().NotBeNull();
bestReport.Should().NotBeNull();
return (bestConfig!, bestReport!);
}
private static bool IsBetter(UnifiedSearchQualityReport left, UnifiedSearchQualityReport right)
{
const double epsilon = 1e-12;
if (left.Overall.NdcgAt10 > right.Overall.NdcgAt10 + epsilon)
{
return true;
}
if (Math.Abs(left.Overall.NdcgAt10 - right.Overall.NdcgAt10) <= epsilon &&
left.Overall.PrecisionAt1 > right.Overall.PrecisionAt1 + epsilon)
{
return true;
}
if (Math.Abs(left.Overall.NdcgAt10 - right.Overall.NdcgAt10) <= epsilon &&
Math.Abs(left.Overall.PrecisionAt1 - right.Overall.PrecisionAt1) <= epsilon &&
string.CompareOrdinal(left.Overall.RankingStabilityHash, right.Overall.RankingStabilityHash) < 0)
{
return true;
}
return false;
}
private static UnifiedSearchOptions BuildBaselineOptions()
{
return new UnifiedSearchOptions
{
BaseDomainWeights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["knowledge"] = 1.0,
["findings"] = 1.0,
["vex"] = 1.0,
["policy"] = 1.0,
["platform"] = 1.0,
["graph"] = 1.0,
["timeline"] = 1.0,
["scanner"] = 1.0,
["opsmemory"] = 1.0
},
Weighting = new UnifiedSearchWeightingOptions
{
CveBoostFindings = 0.20,
CveBoostVex = 0.15,
CveBoostGraph = 0.10,
SecurityBoostFindings = 0.10,
SecurityBoostVex = 0.08,
PolicyBoostPolicy = 0.15,
TroubleshootBoostKnowledge = 0.08,
TroubleshootBoostOpsMemory = 0.05,
PackageBoostGraph = 0.05,
PackageBoostScanner = 0.05,
PackageBoostFindings = 0.02,
AuditBoostTimeline = 0.05,
AuditBoostOpsMemory = 0.03,
FilterDomainMatchBoost = 0.25,
RoleScannerFindingsBoost = 0.05,
RoleScannerVexBoost = 0.05,
RolePolicyBoost = 0.05,
RoleOpsKnowledgeBoost = 0.05,
RoleOpsMemoryBoost = 0.05,
RoleReleasePolicyBoost = 0.05,
RoleReleaseFindingsBoost = 0.05
}
};
}
private static Dictionary<string, double> BuildDefaultBaseWeights()
{
return new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["knowledge"] = 1.0,
["findings"] = 1.0,
["vex"] = 1.0,
["policy"] = 1.0,
["platform"] = 1.0,
["graph"] = 1.0,
["timeline"] = 1.0,
["scanner"] = 1.0,
["opsmemory"] = 1.0
};
}
private static UnifiedSearchQualityCorpus LoadCorpus()
{
var runner = new UnifiedSearchQualityBenchmarkRunner();
var path = ResolveCorpusPath();
return runner.LoadCorpus(path);
}
private static string ResolveCorpusPath()
{
var cursor = new DirectoryInfo(AppContext.BaseDirectory);
while (cursor is not null)
{
var candidate = Path.Combine(
cursor.FullName,
"src",
"AdvisoryAI",
"__Tests",
"StellaOps.AdvisoryAI.Tests",
"TestData",
"unified-search-quality-corpus.json");
if (File.Exists(candidate))
{
return candidate;
}
cursor = cursor.Parent;
}
throw new FileNotFoundException(
"Could not locate unified-search-quality-corpus.json from test base directory.",
"src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TestData/unified-search-quality-corpus.json");
}
}

View File

@@ -0,0 +1,100 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
public sealed class UnifiedSearchScenarioCorpusTests
{
private static readonly Regex QueryRowPattern = new(
@"^\|\s*\d+\s*\|\s*`(?<query>[^`]+)`\s*\|",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Lazy<IReadOnlyList<string>> CorpusQueries = new(LoadCorpusQueries);
[Fact]
public void Scenario_corpus_contains_at_least_1000_queries()
{
var queries = CorpusQueries.Value;
Assert.True(
queries.Count >= 1000,
$"Expected at least 1000 search scenarios, but found {queries.Count}.");
}
[Fact]
public void Scenario_corpus_queries_produce_valid_query_plans()
{
var extractor = new EntityExtractor();
var classifier = new IntentClassifier();
var calculator = new DomainWeightCalculator(
extractor,
classifier,
Options.Create(new KnowledgeSearchOptions()));
var builder = new QueryPlanBuilder(extractor, classifier, calculator);
var queries = CorpusQueries.Value;
foreach (var query in queries)
{
var plan = builder.Build(new UnifiedSearchRequest(query));
Assert.Equal(query, plan.OriginalQuery);
Assert.False(string.IsNullOrWhiteSpace(plan.NormalizedQuery));
Assert.False(string.IsNullOrWhiteSpace(plan.Intent));
Assert.NotEmpty(plan.DomainWeights);
// Unified search ranking depends on these base domains always being present.
Assert.Contains("knowledge", plan.DomainWeights.Keys);
Assert.Contains("findings", plan.DomainWeights.Keys);
Assert.Contains("vex", plan.DomainWeights.Keys);
Assert.Contains("policy", plan.DomainWeights.Keys);
}
}
private static IReadOnlyList<string> LoadCorpusQueries()
{
var corpusPath = ResolveCorpusPath();
var queries = new List<string>(1500);
foreach (var line in File.ReadLines(corpusPath))
{
var match = QueryRowPattern.Match(line);
if (!match.Success)
{
continue;
}
var query = match.Groups["query"].Value.Trim();
if (!string.IsNullOrWhiteSpace(query))
{
queries.Add(query);
}
}
return queries
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string ResolveCorpusPath()
{
var cursor = new DirectoryInfo(AppContext.BaseDirectory);
while (cursor is not null)
{
var candidate = Path.Combine(cursor.FullName, "docs", "qa", "unified-search-test-cases.md");
if (File.Exists(candidate))
{
return candidate;
}
cursor = cursor.Parent;
}
throw new FileNotFoundException(
"Could not locate docs/qa/unified-search-test-cases.md from test base directory.",
Path.Combine("docs", "qa", "unified-search-test-cases.md"));
}
}

View File

@@ -45,6 +45,29 @@ public sealed class UnifiedSearchServiceTests
result.Diagnostics.Mode.Should().Be("disabled");
}
[Fact]
public async Task SearchAsync_returns_empty_when_tenant_feature_flag_disables_search()
{
var unifiedOptions = new UnifiedSearchOptions
{
TenantFeatureFlags = new Dictionary<string, UnifiedSearchTenantFeatureFlags>(StringComparer.OrdinalIgnoreCase)
{
["tenant-a"] = new() { Enabled = false }
}
};
var service = CreateService(unifiedOptions: unifiedOptions);
var result = await service.SearchAsync(
new UnifiedSearchRequest(
"CVE-2025-1201",
Filters: new UnifiedSearchFilter { Tenant = "tenant-a", UserId = "user-1" }),
CancellationToken.None);
result.Cards.Should().BeEmpty();
result.Diagnostics.Mode.Should().Be("disabled");
}
[Fact]
public async Task SearchAsync_returns_entity_cards_from_fts_results()
{
@@ -105,6 +128,159 @@ public sealed class UnifiedSearchServiceTests
result.Cards[0].Severity.Should().Be("critical");
}
[Fact]
public async Task SearchAsync_sanitizes_snippet_html_and_script_content()
{
var ftsRow = MakeRow(
"chunk-xss",
"md_section",
"Security Guide",
snippet: "<mark>critical</mark><script>alert('x')</script> update available");
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { ftsRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var service = CreateService(storeMock: storeMock);
var result = await service.SearchAsync(
new UnifiedSearchRequest("critical update"),
CancellationToken.None);
result.Cards.Should().ContainSingle();
result.Cards[0].Snippet.Should().Be("critical update available");
result.Cards[0].Snippet.Should().NotContain("<");
result.Cards[0].Snippet.Should().NotContain(">");
}
[Fact]
public async Task SearchAsync_known_cve_returns_findings_and_vex_domains()
{
var findingRow = MakeRow(
"chunk-find",
"finding",
"CVE-2024-21626 finding",
JsonDocument.Parse("{\"domain\":\"findings\",\"cveId\":\"CVE-2024-21626\",\"severity\":\"critical\"}"));
var vexRow = MakeRow(
"chunk-vex",
"vex_statement",
"VEX: CVE-2024-21626 (not_affected)",
JsonDocument.Parse("{\"domain\":\"vex\",\"cveId\":\"CVE-2024-21626\",\"status\":\"not_affected\"}"));
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { findingRow, vexRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var service = CreateService(storeMock: storeMock);
var result = await service.SearchAsync(
new UnifiedSearchRequest("CVE-2024-21626"),
CancellationToken.None);
result.Cards.Should().NotBeEmpty();
result.Cards.Select(static card => card.Domain)
.Should().Contain("findings")
.And.Contain("vex");
}
[Fact]
public async Task SearchAsync_known_policy_query_returns_policy_domain_card()
{
var policyRow = MakeRow(
"chunk-policy",
"policy_rule",
"DENY-CRITICAL-PROD",
JsonDocument.Parse("{\"domain\":\"policy\",\"ruleId\":\"DENY-CRITICAL-PROD\",\"enforcement\":\"deny\"}"));
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { policyRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var service = CreateService(storeMock: storeMock);
var result = await service.SearchAsync(
new UnifiedSearchRequest("DENY-CRITICAL-PROD"),
CancellationToken.None);
result.Cards.Should().ContainSingle();
result.Cards[0].Domain.Should().Be("policy");
result.Cards[0].EntityType.Should().Be("policy_rule");
}
[Fact]
public async Task SearchAsync_carries_session_entity_context_for_followup_queries()
{
var cveRow = MakeRow(
"chunk-find",
"finding",
"CVE-2025-1234",
JsonDocument.Parse("{\"domain\":\"findings\",\"cveId\":\"CVE-2025-1234\",\"severity\":\"high\"}"));
var mitigationRow = MakeRow(
"chunk-doc",
"md_section",
"Mitigation playbook",
JsonDocument.Parse("{\"domain\":\"knowledge\",\"path\":\"docs/mitigation.md\"}"));
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { cveRow, mitigationRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var service = CreateService(storeMock: storeMock);
var sharedFilter = new UnifiedSearchFilter
{
Tenant = "tenant-a",
UserId = "user-a"
};
await service.SearchAsync(
new UnifiedSearchRequest(
"CVE-2025-1234",
Filters: sharedFilter,
IncludeSynthesis: false,
Ambient: new AmbientContext { SessionId = "session-ctx-1" }),
CancellationToken.None);
var followup = await service.SearchAsync(
new UnifiedSearchRequest(
"mitigation",
Filters: sharedFilter,
IncludeSynthesis: false,
Ambient: new AmbientContext { SessionId = "session-ctx-1" }),
CancellationToken.None);
followup.Diagnostics.Plan.Should().NotBeNull();
followup.Diagnostics.Plan!.DetectedEntities.Should().Contain(
static mention => mention.EntityType == "cve" && mention.Value == "CVE-2025-1234");
followup.Diagnostics.Plan.ContextEntityBoosts.Should().ContainKey("cve:CVE-2025-1234");
}
[Fact]
public async Task SearchAsync_includes_synthesis_when_requested()
{
@@ -132,6 +308,42 @@ public sealed class UnifiedSearchServiceTests
result.Synthesis.SourceCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task SearchAsync_disables_synthesis_when_tenant_flag_is_off()
{
var ftsRow = MakeRow("chunk-1", "md_section", "Result One");
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { ftsRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var unifiedOptions = new UnifiedSearchOptions
{
TenantFeatureFlags = new Dictionary<string, UnifiedSearchTenantFeatureFlags>(StringComparer.OrdinalIgnoreCase)
{
["tenant-synth-off"] = new() { SynthesisEnabled = false }
}
};
var service = CreateService(storeMock: storeMock, unifiedOptions: unifiedOptions);
var result = await service.SearchAsync(
new UnifiedSearchRequest(
"search query",
IncludeSynthesis: true,
Filters: new UnifiedSearchFilter { Tenant = "tenant-synth-off", UserId = "user-2" }),
CancellationToken.None);
result.Cards.Should().HaveCount(1);
result.Synthesis.Should().BeNull();
}
[Fact]
public async Task SearchAsync_excludes_synthesis_when_not_requested()
{
@@ -535,7 +747,8 @@ public sealed class UnifiedSearchServiceTests
private static UnifiedSearchService CreateService(
bool enabled = true,
Mock<IKnowledgeSearchStore>? storeMock = null)
Mock<IKnowledgeSearchStore>? storeMock = null,
UnifiedSearchOptions? unifiedOptions = null)
{
var options = Options.Create(new KnowledgeSearchOptions
{
@@ -548,6 +761,7 @@ public sealed class UnifiedSearchServiceTests
VectorCandidateCount = 50,
QueryTimeoutMs = 3000
});
var wrappedUnifiedOptions = Options.Create(unifiedOptions ?? new UnifiedSearchOptions());
storeMock ??= new Mock<IKnowledgeSearchStore>();
@@ -580,7 +794,9 @@ public sealed class UnifiedSearchServiceTests
qualityMonitor,
entityAliasService.Object,
logger,
timeProvider);
timeProvider,
telemetrySink: null,
unifiedOptions: wrappedUnifiedOptions);
}
private static KnowledgeChunkRow MakeRow(
@@ -589,7 +805,8 @@ public sealed class UnifiedSearchServiceTests
string title,
JsonDocument? metadata = null,
float[]? embedding = null,
string? body = null)
string? body = null,
string? snippet = null)
{
return new KnowledgeChunkRow(
ChunkId: chunkId,
@@ -601,7 +818,7 @@ public sealed class UnifiedSearchServiceTests
SpanEnd: 100,
Title: title,
Body: body ?? $"Body of {title}",
Snippet: $"Snippet of {title}",
Snippet: snippet ?? $"Snippet of {title}",
Metadata: metadata ?? EmptyMetadata,
Embedding: embedding,
LexicalScore: 1.0);

View File

@@ -16,7 +16,7 @@ public sealed partial class PostgresAlertDedupRepository
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var windowStart = now.AddMinutes(-dedupWindowMinutes);
const string sql = @"

View File

@@ -12,11 +12,13 @@ public sealed partial class PostgresAlertDedupRepository : IAlertDedupRepository
{
private readonly string _connectionString;
private readonly ILogger<PostgresAlertDedupRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresAlertDedupRepository(string connectionString, ILogger<PostgresAlertDedupRepository> logger)
public PostgresAlertDedupRepository(string connectionString, ILogger<PostgresAlertDedupRepository> logger, TimeProvider? timeProvider = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -46,11 +48,13 @@ public sealed partial class PostgresAlertDedupRepository : IAlertDedupRepository
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
var cutoff = _timeProvider.GetUtcNow().AddDays(-7);
const string sql = @"
DELETE FROM attestor.identity_alert_dedup
WHERE last_alert_at < NOW() - INTERVAL '7 days'";
WHERE last_alert_at < @cutoff";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("cutoff", cutoff);
return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,125 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class AirgapAuditEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.AirgapAuditEfEntity",
typeof(AirgapAuditEfEntity),
baseEntityType,
propertyCount: 8,
namedIndexCount: 1,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var componentId = runtimeEntityType.AddProperty(
"ComponentId",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("ComponentId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<ComponentId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
componentId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
componentId.AddAnnotation("Relational:ColumnName", "component_id");
var eventType = runtimeEntityType.AddProperty(
"EventType",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<EventType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
eventType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
eventType.AddAnnotation("Relational:ColumnName", "event_type");
var occurredAt = runtimeEntityType.AddProperty(
"OccurredAt",
typeof(DateTimeOffset),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("OccurredAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<OccurredAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
occurredAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
occurredAt.AddAnnotation("Relational:ColumnName", "occurred_at");
var operatorId = runtimeEntityType.AddProperty(
"OperatorId",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("OperatorId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<OperatorId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
operatorId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
operatorId.AddAnnotation("Relational:ColumnName", "operator_id");
var outcome = runtimeEntityType.AddProperty(
"Outcome",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Outcome", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Outcome>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
outcome.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
outcome.AddAnnotation("Relational:ColumnName", "outcome");
var properties = runtimeEntityType.AddProperty(
"Properties",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
properties.AddAnnotation("Relational:ColumnName", "properties");
properties.AddAnnotation("Relational:ColumnType", "jsonb");
properties.AddAnnotation("Relational:DefaultValueSql", "'[]'::jsonb");
var reason = runtimeEntityType.AddProperty(
"Reason",
typeof(string),
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reason.AddAnnotation("Relational:ColumnName", "reason");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "airgap_audit_pkey");
var idx_airgap_audit_occurred_at = runtimeEntityType.AddIndex(
new[] { occurredAt },
name: "idx_airgap_audit_occurred_at");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "airgap_audit");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,197 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class ApiKeyEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.ApiKeyEfEntity",
typeof(ApiKeyEfEntity),
baseEntityType,
propertyCount: 14,
namedIndexCount: 4,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var keyHash = runtimeEntityType.AddProperty(
"KeyHash",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("KeyHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<KeyHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
keyHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
keyHash.AddAnnotation("Relational:ColumnName", "key_hash");
var keyPrefix = runtimeEntityType.AddProperty(
"KeyPrefix",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("KeyPrefix", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<KeyPrefix>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
keyPrefix.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
keyPrefix.AddAnnotation("Relational:ColumnName", "key_prefix");
var lastUsedAt = runtimeEntityType.AddProperty(
"LastUsedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("LastUsedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<LastUsedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
lastUsedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastUsedAt.AddAnnotation("Relational:ColumnName", "last_used_at");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
name.AddAnnotation("Relational:ColumnName", "name");
var revokedAt = runtimeEntityType.AddProperty(
"RevokedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
var revokedBy = runtimeEntityType.AddProperty(
"RevokedBy",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("RevokedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<RevokedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedBy.AddAnnotation("Relational:ColumnName", "revoked_by");
var scopes = runtimeEntityType.AddProperty(
"Scopes",
typeof(string[]),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Scopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Scopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var scopesElementType = scopes.SetElementType(typeof(string));
scopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
scopes.AddAnnotation("Relational:ColumnName", "scopes");
scopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(Guid?),
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ApiKeyEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "api_keys_pkey");
var idx_api_keys_key_prefix = runtimeEntityType.AddIndex(
new[] { keyPrefix },
name: "idx_api_keys_key_prefix");
var idx_api_keys_status = runtimeEntityType.AddIndex(
new[] { tenantId, status },
name: "idx_api_keys_status");
var idx_api_keys_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_api_keys_tenant_id");
var idx_api_keys_user_id = runtimeEntityType.AddIndex(
new[] { userId },
name: "idx_api_keys_user_id");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "api_keys");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,184 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class AuditEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.AuditEfEntity",
typeof(AuditEfEntity),
baseEntityType,
propertyCount: 12,
namedIndexCount: 6,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(long),
propertyInfo: typeof(AuditEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0L);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
id.AddAnnotation("Relational:ColumnName", "id");
var action = runtimeEntityType.AddProperty(
"Action",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("Action", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<Action>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
action.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
action.AddAnnotation("Relational:ColumnName", "action");
var correlationId = runtimeEntityType.AddProperty(
"CorrelationId",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("CorrelationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<CorrelationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
correlationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
correlationId.AddAnnotation("Relational:ColumnName", "correlation_id");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(AuditEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var ipAddress = runtimeEntityType.AddProperty(
"IpAddress",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
var newValue = runtimeEntityType.AddProperty(
"NewValue",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("NewValue", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<NewValue>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
newValue.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
newValue.AddAnnotation("Relational:ColumnName", "new_value");
newValue.AddAnnotation("Relational:ColumnType", "jsonb");
var oldValue = runtimeEntityType.AddProperty(
"OldValue",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("OldValue", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<OldValue>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
oldValue.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
oldValue.AddAnnotation("Relational:ColumnName", "old_value");
oldValue.AddAnnotation("Relational:ColumnType", "jsonb");
var resourceId = runtimeEntityType.AddProperty(
"ResourceId",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("ResourceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<ResourceId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
resourceId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
resourceId.AddAnnotation("Relational:ColumnName", "resource_id");
var resourceType = runtimeEntityType.AddProperty(
"ResourceType",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("ResourceType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<ResourceType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
resourceType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
resourceType.AddAnnotation("Relational:ColumnName", "resource_type");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var userAgent = runtimeEntityType.AddProperty(
"UserAgent",
typeof(string),
propertyInfo: typeof(AuditEfEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(Guid?),
propertyInfo: typeof(AuditEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(AuditEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "audit_pkey");
var idx_audit_action = runtimeEntityType.AddIndex(
new[] { action },
name: "idx_audit_action");
var idx_audit_correlation_id = runtimeEntityType.AddIndex(
new[] { correlationId },
name: "idx_audit_correlation_id");
var idx_audit_created_at = runtimeEntityType.AddIndex(
new[] { createdAt },
name: "idx_audit_created_at");
var idx_audit_resource = runtimeEntityType.AddIndex(
new[] { resourceType, resourceId },
name: "idx_audit_resource");
var idx_audit_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_audit_tenant_id");
var idx_audit_user_id = runtimeEntityType.AddIndex(
new[] { userId },
name: "idx_audit_user_id");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "audit");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
using StellaOps.Authority.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(AuthorityDbContext), typeof(AuthorityDbContextModel))]

View File

@@ -1,37 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Authority.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for AuthorityDbContext.
/// 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.AuthorityDbContext))]
public partial class AuthorityDbContextModel : RuntimeModel
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
private static AuthorityDbContextModel _instance;
public static IModel Instance
[DbContext(typeof(AuthorityDbContext))]
public partial class AuthorityDbContextModel : RuntimeModel
{
get
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static AuthorityDbContextModel()
{
if (_instance == null)
var model = new AuthorityDbContextModel();
if (_useOldBehavior31751)
{
_instance = new AuthorityDbContextModel();
_instance.Initialize();
_instance.Customize();
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
return _instance;
model.Customize();
_instance = (AuthorityDbContextModel)model.FinalizeModel();
}
private static AuthorityDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
partial void Initialize();
partial void Customize();
}

View File

@@ -1,20 +1,72 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for AuthorityDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class AuthorityDbContextModel
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
partial void Initialize()
public partial class AuthorityDbContextModel
{
// Stub: when a real compiled model is generated, entity types will be registered here.
// The runtime factory will fall back to reflection-based model building for all schemas
// until this stub is replaced with a full compiled model.
private AuthorityDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("b46b998f-a99e-4088-918b-1a65143243e4"), entityTypeCount: 22)
{
}
partial void Initialize()
{
var airgapAuditEfEntity = AirgapAuditEfEntityEntityType.Create(this);
var apiKeyEfEntity = ApiKeyEfEntityEntityType.Create(this);
var auditEfEntity = AuditEfEntityEntityType.Create(this);
var bootstrapInviteEfEntity = BootstrapInviteEfEntityEntityType.Create(this);
var clientEfEntity = ClientEfEntityEntityType.Create(this);
var loginAttemptEfEntity = LoginAttemptEfEntityEntityType.Create(this);
var offlineKitAuditEfEntity = OfflineKitAuditEfEntityEntityType.Create(this);
var oidcRefreshTokenEfEntity = OidcRefreshTokenEfEntityEntityType.Create(this);
var oidcTokenEfEntity = OidcTokenEfEntityEntityType.Create(this);
var permissionEfEntity = PermissionEfEntityEntityType.Create(this);
var refreshTokenEfEntity = RefreshTokenEfEntityEntityType.Create(this);
var revocationEfEntity = RevocationEfEntityEntityType.Create(this);
var revocationExportStateEfEntity = RevocationExportStateEfEntityEntityType.Create(this);
var roleEfEntity = RoleEfEntityEntityType.Create(this);
var rolePermissionEfEntity = RolePermissionEfEntityEntityType.Create(this);
var serviceAccountEfEntity = ServiceAccountEfEntityEntityType.Create(this);
var sessionEfEntity = SessionEfEntityEntityType.Create(this);
var tenantEfEntity = TenantEfEntityEntityType.Create(this);
var tokenEfEntity = TokenEfEntityEntityType.Create(this);
var userEfEntity = UserEfEntityEntityType.Create(this);
var userRoleEfEntity = UserRoleEfEntityEntityType.Create(this);
var verdictManifestEfEntity = VerdictManifestEfEntityEntityType.Create(this);
AirgapAuditEfEntityEntityType.CreateAnnotations(airgapAuditEfEntity);
ApiKeyEfEntityEntityType.CreateAnnotations(apiKeyEfEntity);
AuditEfEntityEntityType.CreateAnnotations(auditEfEntity);
BootstrapInviteEfEntityEntityType.CreateAnnotations(bootstrapInviteEfEntity);
ClientEfEntityEntityType.CreateAnnotations(clientEfEntity);
LoginAttemptEfEntityEntityType.CreateAnnotations(loginAttemptEfEntity);
OfflineKitAuditEfEntityEntityType.CreateAnnotations(offlineKitAuditEfEntity);
OidcRefreshTokenEfEntityEntityType.CreateAnnotations(oidcRefreshTokenEfEntity);
OidcTokenEfEntityEntityType.CreateAnnotations(oidcTokenEfEntity);
PermissionEfEntityEntityType.CreateAnnotations(permissionEfEntity);
RefreshTokenEfEntityEntityType.CreateAnnotations(refreshTokenEfEntity);
RevocationEfEntityEntityType.CreateAnnotations(revocationEfEntity);
RevocationExportStateEfEntityEntityType.CreateAnnotations(revocationExportStateEfEntity);
RoleEfEntityEntityType.CreateAnnotations(roleEfEntity);
RolePermissionEfEntityEntityType.CreateAnnotations(rolePermissionEfEntity);
ServiceAccountEfEntityEntityType.CreateAnnotations(serviceAccountEfEntity);
SessionEfEntityEntityType.CreateAnnotations(sessionEfEntity);
TenantEfEntityEntityType.CreateAnnotations(tenantEfEntity);
TokenEfEntityEntityType.CreateAnnotations(tokenEfEntity);
UserEfEntityEntityType.CreateAnnotations(userEfEntity);
UserRoleEfEntityEntityType.CreateAnnotations(userRoleEfEntity);
VerdictManifestEfEntityEntityType.CreateAnnotations(verdictManifestEfEntity);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,186 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class BootstrapInviteEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.BootstrapInviteEfEntity",
typeof(BootstrapInviteEfEntity),
baseEntityType,
propertyCount: 14,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var consumed = runtimeEntityType.AddProperty(
"Consumed",
typeof(bool),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Consumed", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Consumed>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: false);
consumed.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
consumed.AddAnnotation("Relational:ColumnName", "consumed");
consumed.AddAnnotation("Relational:DefaultValue", false);
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var issuedAt = runtimeEntityType.AddProperty(
"IssuedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
issuedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var issuedBy = runtimeEntityType.AddProperty(
"IssuedBy",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("IssuedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<IssuedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
issuedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuedBy.AddAnnotation("Relational:ColumnName", "issued_by");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var provider = runtimeEntityType.AddProperty(
"Provider",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Provider>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
provider.AddAnnotation("Relational:ColumnName", "provider");
var reservedBy = runtimeEntityType.AddProperty(
"ReservedBy",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("ReservedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<ReservedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
reservedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reservedBy.AddAnnotation("Relational:ColumnName", "reserved_by");
var reservedUntil = runtimeEntityType.AddProperty(
"ReservedUntil",
typeof(DateTimeOffset?),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("ReservedUntil", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<ReservedUntil>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
reservedUntil.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reservedUntil.AddAnnotation("Relational:ColumnName", "reserved_until");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:DefaultValueSql", "'pending'");
var target = runtimeEntityType.AddProperty(
"Target",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Target", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Target>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
target.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
target.AddAnnotation("Relational:ColumnName", "target");
var token = runtimeEntityType.AddProperty(
"Token",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Token", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Token>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
token.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
token.AddAnnotation("Relational:ColumnName", "token");
var type = runtimeEntityType.AddProperty(
"Type",
typeof(string),
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
type.AddAnnotation("Relational:ColumnName", "type");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "bootstrap_invites_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { token });
key0.AddAnnotation("Relational:Name", "bootstrap_invites_token_key");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "bootstrap_invites");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,265 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class ClientEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.ClientEfEntity",
typeof(ClientEfEntity),
baseEntityType,
propertyCount: 21,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var allowPlainTextPkce = runtimeEntityType.AddProperty(
"AllowPlainTextPkce",
typeof(bool),
propertyInfo: typeof(ClientEfEntity).GetProperty("AllowPlainTextPkce", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<AllowPlainTextPkce>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: false);
allowPlainTextPkce.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
allowPlainTextPkce.AddAnnotation("Relational:ColumnName", "allow_plain_text_pkce");
allowPlainTextPkce.AddAnnotation("Relational:DefaultValue", false);
var allowedGrantTypes = runtimeEntityType.AddProperty(
"AllowedGrantTypes",
typeof(string[]),
propertyInfo: typeof(ClientEfEntity).GetProperty("AllowedGrantTypes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<AllowedGrantTypes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var allowedGrantTypesElementType = allowedGrantTypes.SetElementType(typeof(string));
allowedGrantTypes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
allowedGrantTypes.AddAnnotation("Relational:ColumnName", "allowed_grant_types");
allowedGrantTypes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var allowedScopes = runtimeEntityType.AddProperty(
"AllowedScopes",
typeof(string[]),
propertyInfo: typeof(ClientEfEntity).GetProperty("AllowedScopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<AllowedScopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var allowedScopesElementType = allowedScopes.SetElementType(typeof(string));
allowedScopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
allowedScopes.AddAnnotation("Relational:ColumnName", "allowed_scopes");
allowedScopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var certificateBindings = runtimeEntityType.AddProperty(
"CertificateBindings",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("CertificateBindings", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<CertificateBindings>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
certificateBindings.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
certificateBindings.AddAnnotation("Relational:ColumnName", "certificate_bindings");
certificateBindings.AddAnnotation("Relational:ColumnType", "jsonb");
certificateBindings.AddAnnotation("Relational:DefaultValueSql", "'[]'::jsonb");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var clientSecret = runtimeEntityType.AddProperty(
"ClientSecret",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("ClientSecret", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<ClientSecret>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientSecret.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientSecret.AddAnnotation("Relational:ColumnName", "client_secret");
var clientType = runtimeEntityType.AddProperty(
"ClientType",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("ClientType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<ClientType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientType.AddAnnotation("Relational:ColumnName", "client_type");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(ClientEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var description = runtimeEntityType.AddProperty(
"Description",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
description.AddAnnotation("Relational:ColumnName", "description");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var enabled = runtimeEntityType.AddProperty(
"Enabled",
typeof(bool),
propertyInfo: typeof(ClientEfEntity).GetProperty("Enabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<Enabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: true);
enabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
enabled.AddAnnotation("Relational:ColumnName", "enabled");
enabled.AddAnnotation("Relational:DefaultValue", true);
var plugin = runtimeEntityType.AddProperty(
"Plugin",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("Plugin", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<Plugin>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
plugin.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
plugin.AddAnnotation("Relational:ColumnName", "plugin");
var postLogoutRedirectUris = runtimeEntityType.AddProperty(
"PostLogoutRedirectUris",
typeof(string[]),
propertyInfo: typeof(ClientEfEntity).GetProperty("PostLogoutRedirectUris", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<PostLogoutRedirectUris>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var postLogoutRedirectUrisElementType = postLogoutRedirectUris.SetElementType(typeof(string));
postLogoutRedirectUris.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
postLogoutRedirectUris.AddAnnotation("Relational:ColumnName", "post_logout_redirect_uris");
postLogoutRedirectUris.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var properties = runtimeEntityType.AddProperty(
"Properties",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
properties.AddAnnotation("Relational:ColumnName", "properties");
properties.AddAnnotation("Relational:ColumnType", "jsonb");
properties.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var redirectUris = runtimeEntityType.AddProperty(
"RedirectUris",
typeof(string[]),
propertyInfo: typeof(ClientEfEntity).GetProperty("RedirectUris", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<RedirectUris>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var redirectUrisElementType = redirectUris.SetElementType(typeof(string));
redirectUris.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
redirectUris.AddAnnotation("Relational:ColumnName", "redirect_uris");
redirectUris.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var requireClientSecret = runtimeEntityType.AddProperty(
"RequireClientSecret",
typeof(bool),
propertyInfo: typeof(ClientEfEntity).GetProperty("RequireClientSecret", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<RequireClientSecret>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: true);
requireClientSecret.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
requireClientSecret.AddAnnotation("Relational:ColumnName", "require_client_secret");
requireClientSecret.AddAnnotation("Relational:DefaultValue", true);
var requirePkce = runtimeEntityType.AddProperty(
"RequirePkce",
typeof(bool),
propertyInfo: typeof(ClientEfEntity).GetProperty("RequirePkce", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<RequirePkce>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: false);
requirePkce.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
requirePkce.AddAnnotation("Relational:ColumnName", "require_pkce");
requirePkce.AddAnnotation("Relational:DefaultValue", false);
var secretHash = runtimeEntityType.AddProperty(
"SecretHash",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("SecretHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<SecretHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
secretHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
secretHash.AddAnnotation("Relational:ColumnName", "secret_hash");
var senderConstraint = runtimeEntityType.AddProperty(
"SenderConstraint",
typeof(string),
propertyInfo: typeof(ClientEfEntity).GetProperty("SenderConstraint", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<SenderConstraint>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
senderConstraint.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
senderConstraint.AddAnnotation("Relational:ColumnName", "sender_constraint");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(ClientEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ClientEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var key = runtimeEntityType.AddKey(
new[] { clientId });
key.AddAnnotation("Relational:Name", "clients_client_id_key");
var key0 = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key0);
key0.AddAnnotation("Relational:Name", "clients_pkey");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "clients");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,143 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class LoginAttemptEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.LoginAttemptEfEntity",
typeof(LoginAttemptEfEntity),
baseEntityType,
propertyCount: 10,
namedIndexCount: 1,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var eventType = runtimeEntityType.AddProperty(
"EventType",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<EventType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
eventType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
eventType.AddAnnotation("Relational:ColumnName", "event_type");
var ipAddress = runtimeEntityType.AddProperty(
"IpAddress",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
var occurredAt = runtimeEntityType.AddProperty(
"OccurredAt",
typeof(DateTimeOffset),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("OccurredAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<OccurredAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
occurredAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
occurredAt.AddAnnotation("Relational:ColumnName", "occurred_at");
var outcome = runtimeEntityType.AddProperty(
"Outcome",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Outcome", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Outcome>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
outcome.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
outcome.AddAnnotation("Relational:ColumnName", "outcome");
var properties = runtimeEntityType.AddProperty(
"Properties",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
properties.AddAnnotation("Relational:ColumnName", "properties");
properties.AddAnnotation("Relational:ColumnType", "jsonb");
properties.AddAnnotation("Relational:DefaultValueSql", "'[]'::jsonb");
var reason = runtimeEntityType.AddProperty(
"Reason",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reason.AddAnnotation("Relational:ColumnName", "reason");
var subjectId = runtimeEntityType.AddProperty(
"SubjectId",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
var userAgent = runtimeEntityType.AddProperty(
"UserAgent",
typeof(string),
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "login_attempts_pkey");
var idx_login_attempts_subject = runtimeEntityType.AddIndex(
new[] { subjectId, occurredAt },
name: "idx_login_attempts_subject");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "login_attempts");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,126 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class OfflineKitAuditEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.OfflineKitAuditEfEntity",
typeof(OfflineKitAuditEfEntity),
baseEntityType,
propertyCount: 7,
namedIndexCount: 4,
keyCount: 1);
var eventId = runtimeEntityType.AddProperty(
"EventId",
typeof(Guid),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("EventId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<EventId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
eventId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
eventId.AddAnnotation("Relational:ColumnName", "event_id");
var actor = runtimeEntityType.AddProperty(
"Actor",
typeof(string),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
actor.AddAnnotation("Relational:ColumnName", "actor");
var details = runtimeEntityType.AddProperty(
"Details",
typeof(string),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Details", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Details>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
details.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
details.AddAnnotation("Relational:ColumnName", "details");
details.AddAnnotation("Relational:ColumnType", "jsonb");
var eventType = runtimeEntityType.AddProperty(
"EventType",
typeof(string),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<EventType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
eventType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
eventType.AddAnnotation("Relational:ColumnName", "event_type");
var result = runtimeEntityType.AddProperty(
"Result",
typeof(string),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Result", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Result>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
result.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
result.AddAnnotation("Relational:ColumnName", "result");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var timestamp = runtimeEntityType.AddProperty(
"Timestamp",
typeof(DateTimeOffset),
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Timestamp", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Timestamp>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
timestamp.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
timestamp.AddAnnotation("Relational:ColumnName", "timestamp");
var key = runtimeEntityType.AddKey(
new[] { eventId });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "offline_kit_audit_pkey");
var idx_offline_kit_audit_result = runtimeEntityType.AddIndex(
new[] { tenantId, result, timestamp },
name: "idx_offline_kit_audit_result");
var idx_offline_kit_audit_tenant_ts = runtimeEntityType.AddIndex(
new[] { tenantId, timestamp },
name: "idx_offline_kit_audit_tenant_ts");
var idx_offline_kit_audit_ts = runtimeEntityType.AddIndex(
new[] { timestamp },
name: "idx_offline_kit_audit_ts");
var idx_offline_kit_audit_type = runtimeEntityType.AddIndex(
new[] { eventType },
name: "idx_offline_kit_audit_type");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "offline_kit_audit");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,142 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class OidcRefreshTokenEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.OidcRefreshTokenEfEntity",
typeof(OidcRefreshTokenEfEntity),
baseEntityType,
propertyCount: 9,
namedIndexCount: 2,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var consumedAt = runtimeEntityType.AddProperty(
"ConsumedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("ConsumedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<ConsumedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
consumedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
consumedAt.AddAnnotation("Relational:ColumnName", "consumed_at");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var handle = runtimeEntityType.AddProperty(
"Handle",
typeof(string),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("Handle", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<Handle>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
handle.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
handle.AddAnnotation("Relational:ColumnName", "handle");
var payload = runtimeEntityType.AddProperty(
"Payload",
typeof(string),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("Payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<Payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
payload.AddAnnotation("Relational:ColumnName", "payload");
var subjectId = runtimeEntityType.AddProperty(
"SubjectId",
typeof(string),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
var tokenId = runtimeEntityType.AddProperty(
"TokenId",
typeof(string),
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("TokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<TokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenId.AddAnnotation("Relational:ColumnName", "token_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "oidc_refresh_tokens_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { tokenId });
key0.AddAnnotation("Relational:Name", "oidc_refresh_tokens_token_id_key");
var idx_oidc_refresh_tokens_handle = runtimeEntityType.AddIndex(
new[] { handle },
name: "idx_oidc_refresh_tokens_handle");
var idx_oidc_refresh_tokens_subject = runtimeEntityType.AddIndex(
new[] { subjectId },
name: "idx_oidc_refresh_tokens_subject");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "oidc_refresh_tokens");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,165 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class OidcTokenEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.OidcTokenEfEntity",
typeof(OidcTokenEfEntity),
baseEntityType,
propertyCount: 11,
namedIndexCount: 3,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var payload = runtimeEntityType.AddProperty(
"Payload",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("Payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<Payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
payload.AddAnnotation("Relational:ColumnName", "payload");
var properties = runtimeEntityType.AddProperty(
"Properties",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
properties.AddAnnotation("Relational:ColumnName", "properties");
properties.AddAnnotation("Relational:ColumnType", "jsonb");
properties.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var redeemedAt = runtimeEntityType.AddProperty(
"RedeemedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("RedeemedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<RedeemedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
redeemedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
redeemedAt.AddAnnotation("Relational:ColumnName", "redeemed_at");
var referenceId = runtimeEntityType.AddProperty(
"ReferenceId",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("ReferenceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<ReferenceId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
referenceId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
referenceId.AddAnnotation("Relational:ColumnName", "reference_id");
var subjectId = runtimeEntityType.AddProperty(
"SubjectId",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
var tokenId = runtimeEntityType.AddProperty(
"TokenId",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("TokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<TokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenId.AddAnnotation("Relational:ColumnName", "token_id");
var tokenType = runtimeEntityType.AddProperty(
"TokenType",
typeof(string),
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("TokenType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(OidcTokenEfEntity).GetField("<TokenType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tokenType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenType.AddAnnotation("Relational:ColumnName", "token_type");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "oidc_tokens_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { tokenId });
key0.AddAnnotation("Relational:Name", "oidc_tokens_token_id_key");
var idx_oidc_tokens_client = runtimeEntityType.AddIndex(
new[] { clientId },
name: "idx_oidc_tokens_client");
var idx_oidc_tokens_reference = runtimeEntityType.AddIndex(
new[] { referenceId },
name: "idx_oidc_tokens_reference");
var idx_oidc_tokens_subject = runtimeEntityType.AddIndex(
new[] { subjectId },
name: "idx_oidc_tokens_subject");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "oidc_tokens");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,126 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class PermissionEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.PermissionEfEntity",
typeof(PermissionEfEntity),
baseEntityType,
propertyCount: 7,
namedIndexCount: 3,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(PermissionEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var action = runtimeEntityType.AddProperty(
"Action",
typeof(string),
propertyInfo: typeof(PermissionEfEntity).GetProperty("Action", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<Action>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
action.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
action.AddAnnotation("Relational:ColumnName", "action");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(PermissionEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var description = runtimeEntityType.AddProperty(
"Description",
typeof(string),
propertyInfo: typeof(PermissionEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
description.AddAnnotation("Relational:ColumnName", "description");
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(PermissionEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
name.AddAnnotation("Relational:ColumnName", "name");
var resource = runtimeEntityType.AddProperty(
"Resource",
typeof(string),
propertyInfo: typeof(PermissionEfEntity).GetProperty("Resource", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<Resource>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
resource.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
resource.AddAnnotation("Relational:ColumnName", "resource");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(PermissionEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(PermissionEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "permissions_pkey");
var idx_permissions_resource = runtimeEntityType.AddIndex(
new[] { tenantId, resource },
name: "idx_permissions_resource");
var idx_permissions_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_permissions_tenant_id");
var permissions_tenant_id_name_key = runtimeEntityType.AddIndex(
new[] { tenantId, name },
name: "permissions_tenant_id_name_key",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "permissions");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,179 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class RefreshTokenEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.RefreshTokenEfEntity",
typeof(RefreshTokenEfEntity),
baseEntityType,
propertyCount: 12,
namedIndexCount: 3,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var accessTokenId = runtimeEntityType.AddProperty(
"AccessTokenId",
typeof(Guid?),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("AccessTokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<AccessTokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
accessTokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
accessTokenId.AddAnnotation("Relational:ColumnName", "access_token_id");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var issuedAt = runtimeEntityType.AddProperty(
"IssuedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
issuedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var replacedBy = runtimeEntityType.AddProperty(
"ReplacedBy",
typeof(Guid?),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("ReplacedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<ReplacedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
replacedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
replacedBy.AddAnnotation("Relational:ColumnName", "replaced_by");
var revokedAt = runtimeEntityType.AddProperty(
"RevokedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
var revokedBy = runtimeEntityType.AddProperty(
"RevokedBy",
typeof(string),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("RevokedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<RevokedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedBy.AddAnnotation("Relational:ColumnName", "revoked_by");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var tokenHash = runtimeEntityType.AddProperty(
"TokenHash",
typeof(string),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("TokenHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<TokenHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tokenHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenHash.AddAnnotation("Relational:ColumnName", "token_hash");
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(Guid),
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "refresh_tokens_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { tokenHash });
key0.AddAnnotation("Relational:Name", "refresh_tokens_token_hash_key");
var idx_refresh_tokens_expires_at = runtimeEntityType.AddIndex(
new[] { expiresAt },
name: "idx_refresh_tokens_expires_at");
var idx_refresh_tokens_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_refresh_tokens_tenant_id");
var idx_refresh_tokens_user_id = runtimeEntityType.AddIndex(
new[] { userId },
name: "idx_refresh_tokens_user_id");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "refresh_tokens");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,161 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class RevocationEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.RevocationEfEntity",
typeof(RevocationEfEntity),
baseEntityType,
propertyCount: 12,
namedIndexCount: 1,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var category = runtimeEntityType.AddProperty(
"Category",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("Category", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<Category>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
category.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
category.AddAnnotation("Relational:ColumnName", "category");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var effectiveAt = runtimeEntityType.AddProperty(
"EffectiveAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RevocationEfEntity).GetProperty("EffectiveAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<EffectiveAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
effectiveAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
effectiveAt.AddAnnotation("Relational:ColumnName", "effective_at");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(RevocationEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var reason = runtimeEntityType.AddProperty(
"Reason",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reason.AddAnnotation("Relational:ColumnName", "reason");
var reasonDescription = runtimeEntityType.AddProperty(
"ReasonDescription",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("ReasonDescription", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<ReasonDescription>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
reasonDescription.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
reasonDescription.AddAnnotation("Relational:ColumnName", "reason_description");
var revocationId = runtimeEntityType.AddProperty(
"RevocationId",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("RevocationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<RevocationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
revocationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revocationId.AddAnnotation("Relational:ColumnName", "revocation_id");
var revokedAt = runtimeEntityType.AddProperty(
"RevokedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RevocationEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
var subjectId = runtimeEntityType.AddProperty(
"SubjectId",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
var tokenId = runtimeEntityType.AddProperty(
"TokenId",
typeof(string),
propertyInfo: typeof(RevocationEfEntity).GetProperty("TokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationEfEntity).GetField("<TokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
tokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenId.AddAnnotation("Relational:ColumnName", "token_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "revocations_pkey");
var idx_revocations_category_revocation_id = runtimeEntityType.AddIndex(
new[] { category, revocationId },
name: "idx_revocations_category_revocation_id",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "revocations");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,89 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class RevocationExportStateEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.RevocationExportStateEfEntity",
typeof(RevocationExportStateEfEntity),
baseEntityType,
propertyCount: 4,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(int),
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValue", 1);
var bundleId = runtimeEntityType.AddProperty(
"BundleId",
typeof(string),
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("BundleId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<BundleId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
bundleId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleId.AddAnnotation("Relational:ColumnName", "bundle_id");
var issuedAt = runtimeEntityType.AddProperty(
"IssuedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
var sequence = runtimeEntityType.AddProperty(
"Sequence",
typeof(long),
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("Sequence", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<Sequence>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: 0L);
sequence.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
sequence.AddAnnotation("Relational:ColumnName", "sequence");
sequence.AddAnnotation("Relational:DefaultValue", 0L);
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "revocation_export_state_pkey");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "revocation_export_state");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,148 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class RoleEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.RoleEfEntity",
typeof(RoleEfEntity),
baseEntityType,
propertyCount: 9,
namedIndexCount: 2,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(RoleEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RoleEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var description = runtimeEntityType.AddProperty(
"Description",
typeof(string),
propertyInfo: typeof(RoleEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
description.AddAnnotation("Relational:ColumnName", "description");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(RoleEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var isSystem = runtimeEntityType.AddProperty(
"IsSystem",
typeof(bool),
propertyInfo: typeof(RoleEfEntity).GetProperty("IsSystem", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<IsSystem>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: false);
isSystem.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
isSystem.AddAnnotation("Relational:ColumnName", "is_system");
isSystem.AddAnnotation("Relational:DefaultValue", false);
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(RoleEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(RoleEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
name.AddAnnotation("Relational:ColumnName", "name");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(RoleEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RoleEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RoleEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "roles_pkey");
var idx_roles_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_roles_tenant_id");
var roles_tenant_id_name_key = runtimeEntityType.AddIndex(
new[] { tenantId, name },
name: "roles_tenant_id_name_key",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "roles");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,79 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class RolePermissionEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.RolePermissionEfEntity",
typeof(RolePermissionEfEntity),
baseEntityType,
propertyCount: 3,
keyCount: 1);
var roleId = runtimeEntityType.AddProperty(
"RoleId",
typeof(Guid),
propertyInfo: typeof(RolePermissionEfEntity).GetProperty("RoleId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RolePermissionEfEntity).GetField("<RoleId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
roleId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
roleId.AddAnnotation("Relational:ColumnName", "role_id");
var permissionId = runtimeEntityType.AddProperty(
"PermissionId",
typeof(Guid),
propertyInfo: typeof(RolePermissionEfEntity).GetProperty("PermissionId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RolePermissionEfEntity).GetField("<PermissionId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
permissionId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
permissionId.AddAnnotation("Relational:ColumnName", "permission_id");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(RolePermissionEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(RolePermissionEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var key = runtimeEntityType.AddKey(
new[] { roleId, permissionId });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "role_permissions_pkey");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "role_permissions");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,166 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class ServiceAccountEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.ServiceAccountEfEntity",
typeof(ServiceAccountEfEntity),
baseEntityType,
propertyCount: 11,
namedIndexCount: 1,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var accountId = runtimeEntityType.AddProperty(
"AccountId",
typeof(string),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("AccountId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<AccountId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
accountId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
accountId.AddAnnotation("Relational:ColumnName", "account_id");
var allowedScopes = runtimeEntityType.AddProperty(
"AllowedScopes",
typeof(string[]),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("AllowedScopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<AllowedScopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var allowedScopesElementType = allowedScopes.SetElementType(typeof(string));
allowedScopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
allowedScopes.AddAnnotation("Relational:ColumnName", "allowed_scopes");
allowedScopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var attributes = runtimeEntityType.AddProperty(
"Attributes",
typeof(string),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Attributes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Attributes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
attributes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
attributes.AddAnnotation("Relational:ColumnName", "attributes");
attributes.AddAnnotation("Relational:ColumnType", "jsonb");
attributes.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var authorizedClients = runtimeEntityType.AddProperty(
"AuthorizedClients",
typeof(string[]),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("AuthorizedClients", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<AuthorizedClients>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var authorizedClientsElementType = authorizedClients.SetElementType(typeof(string));
authorizedClients.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
authorizedClients.AddAnnotation("Relational:ColumnName", "authorized_clients");
authorizedClients.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var description = runtimeEntityType.AddProperty(
"Description",
typeof(string),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
description.AddAnnotation("Relational:ColumnName", "description");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var enabled = runtimeEntityType.AddProperty(
"Enabled",
typeof(bool),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Enabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Enabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: true);
enabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
enabled.AddAnnotation("Relational:ColumnName", "enabled");
enabled.AddAnnotation("Relational:DefaultValue", true);
var tenant = runtimeEntityType.AddProperty(
"Tenant",
typeof(string),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Tenant", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Tenant>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenant.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenant.AddAnnotation("Relational:ColumnName", "tenant");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var key = runtimeEntityType.AddKey(
new[] { accountId });
key.AddAnnotation("Relational:Name", "service_accounts_account_id_key");
var key0 = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key0);
key0.AddAnnotation("Relational:Name", "service_accounts_pkey");
var idx_service_accounts_tenant = runtimeEntityType.AddIndex(
new[] { tenant },
name: "idx_service_accounts_tenant");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "service_accounts");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,181 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class SessionEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.SessionEfEntity",
typeof(SessionEfEntity),
baseEntityType,
propertyCount: 12,
namedIndexCount: 3,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(SessionEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var endReason = runtimeEntityType.AddProperty(
"EndReason",
typeof(string),
propertyInfo: typeof(SessionEfEntity).GetProperty("EndReason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<EndReason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
endReason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
endReason.AddAnnotation("Relational:ColumnName", "end_reason");
var endedAt = runtimeEntityType.AddProperty(
"EndedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(SessionEfEntity).GetProperty("EndedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<EndedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
endedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
endedAt.AddAnnotation("Relational:ColumnName", "ended_at");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset),
propertyInfo: typeof(SessionEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var ipAddress = runtimeEntityType.AddProperty(
"IpAddress",
typeof(string),
propertyInfo: typeof(SessionEfEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
var lastActivityAt = runtimeEntityType.AddProperty(
"LastActivityAt",
typeof(DateTimeOffset),
propertyInfo: typeof(SessionEfEntity).GetProperty("LastActivityAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<LastActivityAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
lastActivityAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastActivityAt.AddAnnotation("Relational:ColumnName", "last_activity_at");
lastActivityAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(SessionEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var sessionTokenHash = runtimeEntityType.AddProperty(
"SessionTokenHash",
typeof(string),
propertyInfo: typeof(SessionEfEntity).GetProperty("SessionTokenHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<SessionTokenHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
sessionTokenHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
sessionTokenHash.AddAnnotation("Relational:ColumnName", "session_token_hash");
var startedAt = runtimeEntityType.AddProperty(
"StartedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(SessionEfEntity).GetProperty("StartedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<StartedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
startedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
startedAt.AddAnnotation("Relational:ColumnName", "started_at");
startedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(SessionEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var userAgent = runtimeEntityType.AddProperty(
"UserAgent",
typeof(string),
propertyInfo: typeof(SessionEfEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(Guid),
propertyInfo: typeof(SessionEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(SessionEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "sessions_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { sessionTokenHash });
key0.AddAnnotation("Relational:Name", "sessions_session_token_hash_key");
var idx_sessions_expires_at = runtimeEntityType.AddIndex(
new[] { expiresAt },
name: "idx_sessions_expires_at");
var idx_sessions_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_sessions_tenant_id");
var idx_sessions_user_id = runtimeEntityType.AddIndex(
new[] { userId },
name: "idx_sessions_user_id");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "sessions");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,171 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class TenantEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.TenantEfEntity",
typeof(TenantEfEntity),
baseEntityType,
propertyCount: 11,
namedIndexCount: 3,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(TenantEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(TenantEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var createdBy = runtimeEntityType.AddProperty(
"CreatedBy",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
name.AddAnnotation("Relational:ColumnName", "name");
var settings = runtimeEntityType.AddProperty(
"Settings",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("Settings", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<Settings>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
settings.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
settings.AddAnnotation("Relational:ColumnName", "settings");
settings.AddAnnotation("Relational:ColumnType", "jsonb");
settings.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(TenantEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(TenantEfEntity).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TenantEfEntity).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "tenants_pkey");
var idx_tenants_created_at = runtimeEntityType.AddIndex(
new[] { createdAt },
name: "idx_tenants_created_at");
var idx_tenants_status = runtimeEntityType.AddIndex(
new[] { status },
name: "idx_tenants_status");
var tenants_tenant_id_key = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "tenants_tenant_id_key",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "tenants");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,186 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class TokenEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.TokenEfEntity",
typeof(TokenEfEntity),
baseEntityType,
propertyCount: 12,
namedIndexCount: 4,
keyCount: 2);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(TokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var clientId = runtimeEntityType.AddProperty(
"ClientId",
typeof(string),
propertyInfo: typeof(TokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
clientId.AddAnnotation("Relational:ColumnName", "client_id");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset),
propertyInfo: typeof(TokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var issuedAt = runtimeEntityType.AddProperty(
"IssuedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(TokenEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
issuedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(TokenEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var revokedAt = runtimeEntityType.AddProperty(
"RevokedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(TokenEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
var revokedBy = runtimeEntityType.AddProperty(
"RevokedBy",
typeof(string),
propertyInfo: typeof(TokenEfEntity).GetProperty("RevokedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<RevokedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
revokedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
revokedBy.AddAnnotation("Relational:ColumnName", "revoked_by");
var scopes = runtimeEntityType.AddProperty(
"Scopes",
typeof(string[]),
propertyInfo: typeof(TokenEfEntity).GetProperty("Scopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<Scopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
var scopesElementType = scopes.SetElementType(typeof(string));
scopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
scopes.AddAnnotation("Relational:ColumnName", "scopes");
scopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(TokenEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var tokenHash = runtimeEntityType.AddProperty(
"TokenHash",
typeof(string),
propertyInfo: typeof(TokenEfEntity).GetProperty("TokenHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<TokenHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tokenHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenHash.AddAnnotation("Relational:ColumnName", "token_hash");
var tokenType = runtimeEntityType.AddProperty(
"TokenType",
typeof(string),
propertyInfo: typeof(TokenEfEntity).GetProperty("TokenType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<TokenType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
tokenType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tokenType.AddAnnotation("Relational:ColumnName", "token_type");
tokenType.AddAnnotation("Relational:DefaultValueSql", "'access'");
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(Guid?),
propertyInfo: typeof(TokenEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TokenEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "tokens_pkey");
var key0 = runtimeEntityType.AddKey(
new[] { tokenHash });
key0.AddAnnotation("Relational:Name", "tokens_token_hash_key");
var idx_tokens_expires_at = runtimeEntityType.AddIndex(
new[] { expiresAt },
name: "idx_tokens_expires_at");
var idx_tokens_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_tokens_tenant_id");
var idx_tokens_token_hash = runtimeEntityType.AddIndex(
new[] { tokenHash },
name: "idx_tokens_token_hash");
var idx_tokens_user_id = runtimeEntityType.AddIndex(
new[] { userId },
name: "idx_tokens_user_id");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "tokens");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,325 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class UserEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.UserEfEntity",
typeof(UserEfEntity),
baseEntityType,
propertyCount: 26,
namedIndexCount: 5,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(UserEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(UserEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var createdBy = runtimeEntityType.AddProperty(
"CreatedBy",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
var displayName = runtimeEntityType.AddProperty(
"DisplayName",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
displayName.AddAnnotation("Relational:ColumnName", "display_name");
var email = runtimeEntityType.AddProperty(
"Email",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("Email", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Email>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
email.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
email.AddAnnotation("Relational:ColumnName", "email");
var emailVerified = runtimeEntityType.AddProperty(
"EmailVerified",
typeof(bool),
propertyInfo: typeof(UserEfEntity).GetProperty("EmailVerified", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<EmailVerified>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: false);
emailVerified.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
emailVerified.AddAnnotation("Relational:ColumnName", "email_verified");
emailVerified.AddAnnotation("Relational:DefaultValue", false);
var enabled = runtimeEntityType.AddProperty(
"Enabled",
typeof(bool),
propertyInfo: typeof(UserEfEntity).GetProperty("Enabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Enabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: true);
enabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
enabled.AddAnnotation("Relational:ColumnName", "enabled");
enabled.AddAnnotation("Relational:DefaultValue", true);
var failedLoginAttempts = runtimeEntityType.AddProperty(
"FailedLoginAttempts",
typeof(int),
propertyInfo: typeof(UserEfEntity).GetProperty("FailedLoginAttempts", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<FailedLoginAttempts>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: 0);
failedLoginAttempts.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
failedLoginAttempts.AddAnnotation("Relational:ColumnName", "failed_login_attempts");
failedLoginAttempts.AddAnnotation("Relational:DefaultValue", 0);
var lastLoginAt = runtimeEntityType.AddProperty(
"LastLoginAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(UserEfEntity).GetProperty("LastLoginAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<LastLoginAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
lastLoginAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastLoginAt.AddAnnotation("Relational:ColumnName", "last_login_at");
var lastPasswordChangeAt = runtimeEntityType.AddProperty(
"LastPasswordChangeAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(UserEfEntity).GetProperty("LastPasswordChangeAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<LastPasswordChangeAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
lastPasswordChangeAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastPasswordChangeAt.AddAnnotation("Relational:ColumnName", "last_password_change_at");
var lockedUntil = runtimeEntityType.AddProperty(
"LockedUntil",
typeof(DateTimeOffset?),
propertyInfo: typeof(UserEfEntity).GetProperty("LockedUntil", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<LockedUntil>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
lockedUntil.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lockedUntil.AddAnnotation("Relational:ColumnName", "locked_until");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var mfaBackupCodes = runtimeEntityType.AddProperty(
"MfaBackupCodes",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("MfaBackupCodes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<MfaBackupCodes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
mfaBackupCodes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
mfaBackupCodes.AddAnnotation("Relational:ColumnName", "mfa_backup_codes");
var mfaEnabled = runtimeEntityType.AddProperty(
"MfaEnabled",
typeof(bool),
propertyInfo: typeof(UserEfEntity).GetProperty("MfaEnabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<MfaEnabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: false);
mfaEnabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
mfaEnabled.AddAnnotation("Relational:ColumnName", "mfa_enabled");
mfaEnabled.AddAnnotation("Relational:DefaultValue", false);
var mfaSecret = runtimeEntityType.AddProperty(
"MfaSecret",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("MfaSecret", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<MfaSecret>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
mfaSecret.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
mfaSecret.AddAnnotation("Relational:ColumnName", "mfa_secret");
var passwordAlgorithm = runtimeEntityType.AddProperty(
"PasswordAlgorithm",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordAlgorithm", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<PasswordAlgorithm>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true,
valueGenerated: ValueGenerated.OnAdd);
passwordAlgorithm.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
passwordAlgorithm.AddAnnotation("Relational:ColumnName", "password_algorithm");
passwordAlgorithm.AddAnnotation("Relational:DefaultValueSql", "'argon2id'");
var passwordChangedAt = runtimeEntityType.AddProperty(
"PasswordChangedAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordChangedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<PasswordChangedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
passwordChangedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
passwordChangedAt.AddAnnotation("Relational:ColumnName", "password_changed_at");
var passwordExpiresAt = runtimeEntityType.AddProperty(
"PasswordExpiresAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<PasswordExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
passwordExpiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
passwordExpiresAt.AddAnnotation("Relational:ColumnName", "password_expires_at");
var passwordHash = runtimeEntityType.AddProperty(
"PasswordHash",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<PasswordHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
passwordHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
passwordHash.AddAnnotation("Relational:ColumnName", "password_hash");
var passwordSalt = runtimeEntityType.AddProperty(
"PasswordSalt",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordSalt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<PasswordSalt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
passwordSalt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
passwordSalt.AddAnnotation("Relational:ColumnName", "password_salt");
var settings = runtimeEntityType.AddProperty(
"Settings",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("Settings", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Settings>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
settings.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
settings.AddAnnotation("Relational:ColumnName", "settings");
settings.AddAnnotation("Relational:ColumnType", "jsonb");
settings.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(UserEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var updatedBy = runtimeEntityType.AddProperty(
"UpdatedBy",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
var username = runtimeEntityType.AddProperty(
"Username",
typeof(string),
propertyInfo: typeof(UserEfEntity).GetProperty("Username", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserEfEntity).GetField("<Username>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
username.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
username.AddAnnotation("Relational:ColumnName", "username");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "users_pkey");
var idx_users_email = runtimeEntityType.AddIndex(
new[] { tenantId, email },
name: "idx_users_email");
var idx_users_status = runtimeEntityType.AddIndex(
new[] { tenantId, status },
name: "idx_users_status");
var idx_users_tenant_id = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_users_tenant_id");
var users_tenant_id_email_key = runtimeEntityType.AddIndex(
new[] { tenantId, email },
name: "users_tenant_id_email_key",
unique: true);
var users_tenant_id_username_key = runtimeEntityType.AddIndex(
new[] { tenantId, username },
name: "users_tenant_id_username_key",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "users");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,97 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class UserRoleEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.UserRoleEfEntity",
typeof(UserRoleEfEntity),
baseEntityType,
propertyCount: 5,
keyCount: 1);
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(Guid),
propertyInfo: typeof(UserRoleEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserRoleEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var roleId = runtimeEntityType.AddProperty(
"RoleId",
typeof(Guid),
propertyInfo: typeof(UserRoleEfEntity).GetProperty("RoleId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserRoleEfEntity).GetField("<RoleId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
roleId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
roleId.AddAnnotation("Relational:ColumnName", "role_id");
var expiresAt = runtimeEntityType.AddProperty(
"ExpiresAt",
typeof(DateTimeOffset?),
propertyInfo: typeof(UserRoleEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserRoleEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
var grantedAt = runtimeEntityType.AddProperty(
"GrantedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(UserRoleEfEntity).GetProperty("GrantedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserRoleEfEntity).GetField("<GrantedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
grantedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
grantedAt.AddAnnotation("Relational:ColumnName", "granted_at");
grantedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var grantedBy = runtimeEntityType.AddProperty(
"GrantedBy",
typeof(string),
propertyInfo: typeof(UserRoleEfEntity).GetProperty("GrantedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(UserRoleEfEntity).GetField("<GrantedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
grantedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
grantedBy.AddAnnotation("Relational:ColumnName", "granted_by");
var key = runtimeEntityType.AddKey(
new[] { userId, roleId });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "user_roles_pkey");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "user_roles");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,212 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.Authority.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class VerdictManifestEfEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.Authority.Persistence.EfCore.Models.VerdictManifestEfEntity",
typeof(VerdictManifestEfEntity),
baseEntityType,
propertyCount: 16,
namedIndexCount: 5,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(Guid),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
var assetDigest = runtimeEntityType.AddProperty(
"AssetDigest",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("AssetDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<AssetDigest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
assetDigest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
assetDigest.AddAnnotation("Relational:ColumnName", "asset_digest");
var confidence = runtimeEntityType.AddProperty(
"Confidence",
typeof(double),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Confidence", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Confidence>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0.0);
confidence.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
confidence.AddAnnotation("Relational:ColumnName", "confidence");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var evaluatedAt = runtimeEntityType.AddProperty(
"EvaluatedAt",
typeof(DateTimeOffset),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("EvaluatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<EvaluatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
evaluatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
evaluatedAt.AddAnnotation("Relational:ColumnName", "evaluated_at");
var inputsJson = runtimeEntityType.AddProperty(
"InputsJson",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("InputsJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<InputsJson>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
inputsJson.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
inputsJson.AddAnnotation("Relational:ColumnName", "inputs_json");
inputsJson.AddAnnotation("Relational:ColumnType", "jsonb");
var latticeVersion = runtimeEntityType.AddProperty(
"LatticeVersion",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("LatticeVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<LatticeVersion>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
latticeVersion.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
latticeVersion.AddAnnotation("Relational:ColumnName", "lattice_version");
var manifestDigest = runtimeEntityType.AddProperty(
"ManifestDigest",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("ManifestDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<ManifestDigest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
manifestDigest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
manifestDigest.AddAnnotation("Relational:ColumnName", "manifest_digest");
var manifestId = runtimeEntityType.AddProperty(
"ManifestId",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("ManifestId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<ManifestId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
manifestId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
manifestId.AddAnnotation("Relational:ColumnName", "manifest_id");
var policyHash = runtimeEntityType.AddProperty(
"PolicyHash",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("PolicyHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<PolicyHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
policyHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
policyHash.AddAnnotation("Relational:ColumnName", "policy_hash");
var rekorLogId = runtimeEntityType.AddProperty(
"RekorLogId",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("RekorLogId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<RekorLogId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
rekorLogId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
rekorLogId.AddAnnotation("Relational:ColumnName", "rekor_log_id");
var resultJson = runtimeEntityType.AddProperty(
"ResultJson",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("ResultJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<ResultJson>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
resultJson.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
resultJson.AddAnnotation("Relational:ColumnName", "result_json");
resultJson.AddAnnotation("Relational:ColumnType", "jsonb");
var signatureBase64 = runtimeEntityType.AddProperty(
"SignatureBase64",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("SignatureBase64", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<SignatureBase64>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
signatureBase64.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
signatureBase64.AddAnnotation("Relational:ColumnName", "signature_base64");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
var tenant = runtimeEntityType.AddProperty(
"Tenant",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Tenant", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Tenant>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenant.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenant.AddAnnotation("Relational:ColumnName", "tenant");
var vulnerabilityId = runtimeEntityType.AddProperty(
"VulnerabilityId",
typeof(string),
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("VulnerabilityId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<VulnerabilityId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
vulnerabilityId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
vulnerabilityId.AddAnnotation("Relational:ColumnName", "vulnerability_id");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "verdict_manifests_pkey");
var idx_verdict_asset_vuln = runtimeEntityType.AddIndex(
new[] { tenant, assetDigest, vulnerabilityId },
name: "idx_verdict_asset_vuln");
var idx_verdict_digest = runtimeEntityType.AddIndex(
new[] { manifestDigest },
name: "idx_verdict_digest");
var idx_verdict_policy = runtimeEntityType.AddIndex(
new[] { tenant, policyHash, latticeVersion },
name: "idx_verdict_policy");
var idx_verdict_replay = runtimeEntityType.AddIndex(
new[] { tenant, assetDigest, vulnerabilityId, policyHash, latticeVersion },
name: "idx_verdict_replay",
unique: true);
var uq_verdict_manifest_id = runtimeEntityType.AddIndex(
new[] { tenant, manifestId },
name: "uq_verdict_manifest_id",
unique: true);
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "verdict_manifests");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -1,15 +1,15 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
using StellaOps.Authority.Persistence.EfCore.Context;
namespace StellaOps.Authority.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="AuthorityDbContext"/> instances.
/// Always uses reflection-based model building from <see cref="AuthorityDbContext.OnModelCreating"/>.
/// When a real compiled model is generated via <c>dotnet ef dbcontext optimize</c>,
/// re-enable UseModel() here.
/// Uses the static compiled model for the default schema and falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class AuthorityDbContextFactory
{
@@ -22,6 +22,11 @@ internal static class AuthorityDbContextFactory
var optionsBuilder = new DbContextOptionsBuilder<AuthorityDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, AuthorityDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
optionsBuilder.UseModel(AuthorityDbContextModel.Instance);
}
return new AuthorityDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -14,11 +14,13 @@ public sealed class ApiKeyRepository : IApiKeyRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<ApiKeyRepository> _logger;
private readonly TimeProvider _timeProvider;
public ApiKeyRepository(AuthorityDataSource dataSource, ILogger<ApiKeyRepository> logger)
public ApiKeyRepository(AuthorityDataSource dataSource, ILogger<ApiKeyRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -107,9 +109,10 @@ public sealed class ApiKeyRepository : IApiKeyRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = {0} AND id = {1}",
[tenantId, id],
"UPDATE authority.api_keys SET last_used_at = {0} WHERE tenant_id = {1} AND id = {2}",
[now, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -118,12 +121,13 @@ public sealed class ApiKeyRepository : IApiKeyRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND id = {2} AND status = 'active'
UPDATE authority.api_keys SET status = 'revoked', revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND id = {3} AND status = 'active'
""",
[revokedBy, tenantId, id],
[now, revokedBy, tenantId, id],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -16,11 +16,13 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<OidcTokenRepository> _logger;
private readonly TimeProvider _timeProvider;
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger)
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default)
@@ -334,14 +336,15 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for NOW() to preserve DB clock semantics.
// Use app-side timestamp via TimeProvider for consumed_at.
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.oidc_refresh_tokens
SET consumed_at = NOW()
WHERE token_id = {0} AND consumed_at IS NULL
SET consumed_at = {0}
WHERE token_id = {1} AND consumed_at IS NULL
""",
tokenId,
now, tokenId,
cancellationToken).ConfigureAwait(false);
return rows > 0;

View File

@@ -14,11 +14,13 @@ public sealed class PermissionRepository : IPermissionRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<PermissionRepository> _logger;
private readonly TimeProvider _timeProvider;
public PermissionRepository(AuthorityDataSource dataSource, ILogger<PermissionRepository> logger)
public PermissionRepository(AuthorityDataSource dataSource, ILogger<PermissionRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -106,7 +108,8 @@ public sealed class PermissionRepository : IPermissionRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for multi-JOIN with NOW() filtering to preserve exact SQL semantics.
// Use app-side timestamp via TimeProvider for consistent clock behavior in role expiry check.
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.Permissions
.FromSqlRaw(
"""
@@ -115,10 +118,10 @@ public sealed class PermissionRepository : IPermissionRepository
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
INNER JOIN authority.user_roles ur ON rp.role_id = ur.role_id
WHERE p.tenant_id = {0} AND ur.user_id = {1}
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
AND (ur.expires_at IS NULL OR ur.expires_at > {2})
ORDER BY p.resource, p.action
""",
tenantId, userId)
tenantId, userId, now)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

View File

@@ -14,11 +14,13 @@ public sealed class RoleRepository : IRoleRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<RoleRepository> _logger;
private readonly TimeProvider _timeProvider;
public RoleRepository(AuthorityDataSource dataSource, ILogger<RoleRepository> logger)
public RoleRepository(AuthorityDataSource dataSource, ILogger<RoleRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -67,7 +69,8 @@ public sealed class RoleRepository : IRoleRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for the JOIN + NOW() comparison to preserve exact SQL semantics.
// Use app-side timestamp via TimeProvider for consistent clock behavior in role expiry check.
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.Roles
.FromSqlRaw(
"""
@@ -75,10 +78,10 @@ public sealed class RoleRepository : IRoleRepository
FROM authority.roles r
INNER JOIN authority.user_roles ur ON r.id = ur.role_id
WHERE r.tenant_id = {0} AND ur.user_id = {1}
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
AND (ur.expires_at IS NULL OR ur.expires_at > {2})
ORDER BY r.name
""",
tenantId, userId)
tenantId, userId, now)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -144,17 +147,19 @@ public sealed class RoleRepository : IRoleRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for ON CONFLICT DO UPDATE with NOW() to preserve exact SQL behavior.
// Use app-side timestamp via TimeProvider for granted_at on conflict.
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
INSERT INTO authority.user_roles (user_id, role_id, granted_by, expires_at)
VALUES ({0}, {1}, {2}, {3})
ON CONFLICT (user_id, role_id) DO UPDATE SET
granted_at = NOW(), granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
granted_at = {4}, granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
""",
userId, roleId,
(object?)grantedBy ?? DBNull.Value,
(object?)expiresAt ?? DBNull.Value,
now,
cancellationToken).ConfigureAwait(false);
}

View File

@@ -14,11 +14,13 @@ public sealed class SessionRepository : ISessionRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<SessionRepository> _logger;
private readonly TimeProvider _timeProvider;
public SessionRepository(AuthorityDataSource dataSource, ILogger<SessionRepository> logger)
public SessionRepository(AuthorityDataSource dataSource, ILogger<SessionRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<SessionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -39,13 +41,14 @@ public sealed class SessionRepository : ISessionRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.Sessions
.FromSqlRaw(
"""
SELECT * FROM authority.sessions
WHERE session_token_hash = {0} AND ended_at IS NULL AND expires_at > NOW()
WHERE session_token_hash = {0} AND ended_at IS NULL AND expires_at > {1}
""",
sessionTokenHash)
sessionTokenHash, now)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -61,15 +64,16 @@ public sealed class SessionRepository : ISessionRepository
if (activeOnly)
{
// Use raw SQL for NOW() comparison consistency.
// Use app-side timestamp via TimeProvider for consistent clock behavior.
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.Sessions
.FromSqlRaw(
"""
SELECT * FROM authority.sessions
WHERE tenant_id = {0} AND user_id = {1} AND ended_at IS NULL AND expires_at > NOW()
WHERE tenant_id = {0} AND user_id = {1} AND ended_at IS NULL AND expires_at > {2}
ORDER BY started_at DESC
""",
tenantId, userId)
tenantId, userId, now)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -117,9 +121,10 @@ public sealed class SessionRepository : ISessionRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE authority.sessions SET last_activity_at = NOW() WHERE tenant_id = {0} AND id = {1} AND ended_at IS NULL",
[tenantId, id],
"UPDATE authority.sessions SET last_activity_at = {0} WHERE tenant_id = {1} AND id = {2} AND ended_at IS NULL",
[now, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -128,12 +133,13 @@ public sealed class SessionRepository : ISessionRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
WHERE tenant_id = {1} AND id = {2} AND ended_at IS NULL
UPDATE authority.sessions SET ended_at = {0}, end_reason = {1}
WHERE tenant_id = {2} AND id = {3} AND ended_at IS NULL
""",
[reason, tenantId, id],
[now, reason, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -142,12 +148,13 @@ public sealed class SessionRepository : ISessionRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
WHERE tenant_id = {1} AND user_id = {2} AND ended_at IS NULL
UPDATE authority.sessions SET ended_at = {0}, end_reason = {1}
WHERE tenant_id = {2} AND user_id = {3} AND ended_at IS NULL
""",
[reason, tenantId, userId],
[now, reason, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}
@@ -156,9 +163,10 @@ public sealed class SessionRepository : ISessionRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var cutoff = _timeProvider.GetUtcNow().AddDays(-30);
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.sessions WHERE expires_at < NOW() - INTERVAL '30 days'",
[],
"DELETE FROM authority.sessions WHERE expires_at < {0}",
[cutoff],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -14,11 +14,13 @@ public sealed class TokenRepository : ITokenRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<TokenRepository> _logger;
private readonly TimeProvider _timeProvider;
public TokenRepository(AuthorityDataSource dataSource, ILogger<TokenRepository> logger)
public TokenRepository(AuthorityDataSource dataSource, ILogger<TokenRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -39,14 +41,15 @@ public sealed class TokenRepository : ITokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL for NOW() comparison to preserve DB clock semantics.
// Use app-side timestamp via TimeProvider for consistent clock behavior.
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.Tokens
.FromSqlRaw(
"""
SELECT * FROM authority.tokens
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > NOW()
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > {1}
""",
tokenHash)
tokenHash, now)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -100,13 +103,14 @@ public sealed class TokenRepository : ITokenRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL to preserve NOW() for revoked_at.
// Use app-side timestamp via TimeProvider for revoked_at.
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND id = {2} AND revoked_at IS NULL
UPDATE authority.tokens SET revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
""",
[revokedBy, tenantId, id],
[now, revokedBy, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -115,12 +119,13 @@ public sealed class TokenRepository : ITokenRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
UPDATE authority.tokens SET revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND user_id = {3} AND revoked_at IS NULL
""",
[revokedBy, tenantId, userId],
[now, revokedBy, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}
@@ -129,9 +134,10 @@ public sealed class TokenRepository : ITokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var cutoff = _timeProvider.GetUtcNow().AddDays(-7);
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'",
[],
"DELETE FROM authority.tokens WHERE expires_at < {0}",
[cutoff],
cancellationToken).ConfigureAwait(false);
}
@@ -163,11 +169,13 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<RefreshTokenRepository> _logger;
private readonly TimeProvider _timeProvider;
public RefreshTokenRepository(AuthorityDataSource dataSource, ILogger<RefreshTokenRepository> logger)
public RefreshTokenRepository(AuthorityDataSource dataSource, ILogger<RefreshTokenRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -188,13 +196,14 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var entities = await dbContext.RefreshTokens
.FromSqlRaw(
"""
SELECT * FROM authority.refresh_tokens
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > NOW()
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > {1}
""",
tokenHash)
tokenHash, now)
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -247,12 +256,13 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}, replaced_by = {1}
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
UPDATE authority.refresh_tokens SET revoked_at = {0}, revoked_by = {1}, replaced_by = {2}
WHERE tenant_id = {3} AND id = {4} AND revoked_at IS NULL
""",
[revokedBy, (object?)replacedBy ?? DBNull.Value, tenantId, id],
[now, revokedBy, (object?)replacedBy ?? DBNull.Value, tenantId, id],
cancellationToken).ConfigureAwait(false);
}
@@ -261,12 +271,13 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
UPDATE authority.refresh_tokens SET revoked_at = {0}, revoked_by = {1}
WHERE tenant_id = {2} AND user_id = {3} AND revoked_at IS NULL
""",
[revokedBy, tenantId, userId],
[now, revokedBy, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}
@@ -275,9 +286,10 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var cutoff = _timeProvider.GetUtcNow().AddDays(-30);
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'",
[],
"DELETE FROM authority.refresh_tokens WHERE expires_at < {0}",
[cutoff],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -14,11 +14,13 @@ public sealed class UserRepository : IUserRepository
private readonly AuthorityDataSource _dataSource;
private readonly ILogger<UserRepository> _logger;
private readonly TimeProvider _timeProvider;
public UserRepository(AuthorityDataSource dataSource, ILogger<UserRepository> logger)
public UserRepository(AuthorityDataSource dataSource, ILogger<UserRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
@@ -184,14 +186,15 @@ public sealed class UserRepository : IUserRepository
.ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL to preserve NOW() for password_changed_at (DB-generated timestamp).
// Use app-side timestamp via TimeProvider for password_changed_at.
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.users
SET password_hash = {0}, password_salt = {1}, password_changed_at = NOW()
WHERE tenant_id = {2} AND id = {3}
SET password_hash = {0}, password_salt = {1}, password_changed_at = {2}
WHERE tenant_id = {3} AND id = {4}
""",
[passwordHash, passwordSalt, tenantId, userId],
[passwordHash, passwordSalt, now, tenantId, userId],
cancellationToken).ConfigureAwait(false);
return rows > 0;
@@ -231,14 +234,15 @@ public sealed class UserRepository : IUserRepository
.ConfigureAwait(false);
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
// Use raw SQL to preserve NOW() for last_login_at (DB-generated timestamp).
// Use app-side timestamp via TimeProvider for last_login_at.
var now = _timeProvider.GetUtcNow();
await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE authority.users
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW()
WHERE tenant_id = {0} AND id = {1}
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = {0}
WHERE tenant_id = {1} AND id = {2}
""",
[tenantId, userId],
[now, tenantId, userId],
cancellationToken).ConfigureAwait(false);
}

View File

@@ -0,0 +1,76 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
using StellaOps.Authority.Persistence.EfCore.Models;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Authority.Persistence.Tests;
/// <summary>
/// Guard tests ensuring the EF Core compiled model is real (not a stub)
/// and contains all expected entity type registrations.
/// </summary>
public sealed class CompiledModelGuardTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_Instance_IsNotNull()
{
AuthorityDbContextModel.Instance.Should().NotBeNull(
"compiled model must be generated via 'dotnet ef dbcontext optimize', not a stub");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_HasExpectedEntityTypeCount()
{
var entityTypes = AuthorityDbContextModel.Instance.GetEntityTypes().ToList();
entityTypes.Should().HaveCount(22,
"authority compiled model must contain exactly 22 entity types (regenerate with 'dotnet ef dbcontext optimize' if count differs)");
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(typeof(TenantEfEntity))]
[InlineData(typeof(UserEfEntity))]
[InlineData(typeof(RoleEfEntity))]
[InlineData(typeof(PermissionEfEntity))]
[InlineData(typeof(RolePermissionEfEntity))]
[InlineData(typeof(UserRoleEfEntity))]
[InlineData(typeof(ApiKeyEfEntity))]
[InlineData(typeof(TokenEfEntity))]
[InlineData(typeof(RefreshTokenEfEntity))]
[InlineData(typeof(SessionEfEntity))]
[InlineData(typeof(AuditEfEntity))]
[InlineData(typeof(BootstrapInviteEfEntity))]
[InlineData(typeof(ServiceAccountEfEntity))]
[InlineData(typeof(ClientEfEntity))]
[InlineData(typeof(RevocationEfEntity))]
[InlineData(typeof(LoginAttemptEfEntity))]
[InlineData(typeof(OidcTokenEfEntity))]
[InlineData(typeof(OidcRefreshTokenEfEntity))]
[InlineData(typeof(AirgapAuditEfEntity))]
[InlineData(typeof(RevocationExportStateEfEntity))]
[InlineData(typeof(OfflineKitAuditEfEntity))]
[InlineData(typeof(VerdictManifestEfEntity))]
public void CompiledModel_ContainsEntityType(Type entityType)
{
var found = AuthorityDbContextModel.Instance.FindEntityType(entityType);
found.Should().NotBeNull(
$"compiled model must contain entity type '{entityType.Name}' — regenerate if missing");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_EntityTypes_HaveTableNames()
{
var entityTypes = AuthorityDbContextModel.Instance.GetEntityTypes();
foreach (var entityType in entityTypes)
{
var tableName = entityType.GetTableName();
tableName.Should().NotBeNullOrWhiteSpace(
$"entity type '{entityType.ClrType.Name}' must have a table name configured");
}
}
}

View File

@@ -13,14 +13,17 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
{
private readonly BinaryIndexDbContext _connectionContext;
private readonly ILogger<CorpusSnapshotRepository> _logger;
private readonly TimeProvider _timeProvider;
private const int CommandTimeoutSeconds = 30;
public CorpusSnapshotRepository(
BinaryIndexDbContext connectionContext,
ILogger<CorpusSnapshotRepository> logger)
ILogger<CorpusSnapshotRepository> logger,
TimeProvider? timeProvider = null)
{
_connectionContext = connectionContext;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<CorpusSnapshot> CreateAsync(CorpusSnapshot snapshot, CancellationToken ct = default)
@@ -31,6 +34,7 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
var snapshotIdValue = $"{snapshot.Distro}_{snapshot.Release}_{snapshot.Architecture}_{snapshot.CapturedAt:yyyyMMddHHmmss}";
// Use raw SQL for INSERT ... RETURNING with tenant function
var now = _timeProvider.GetUtcNow();
var results = await dbContext.CorpusSnapshots
.FromSqlInterpolated($"""
INSERT INTO binaries.corpus_snapshots (
@@ -41,7 +45,7 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
{snapshot.Id},
binaries_app.require_current_tenant()::uuid,
{snapshot.Distro}, {snapshot.Release}, {snapshot.Architecture},
{snapshotIdValue}, {snapshot.MetadataDigest}, NOW()
{snapshotIdValue}, {snapshot.MetadataDigest}, {now}
)
RETURNING id, tenant_id, distro, release, architecture, snapshot_id,
packages_processed, binaries_indexed, repo_metadata_digest,

Some files were not shown because too many files have changed in this diff Show More