Advisory AI architecture

Captures the retrieval, guardrail, and inference packaging requirements defined in the Advisory AI implementation plan and related module guides. Configuration knobs (inference modes, guardrails, cache/queue budgets) now live in docs/modules/policy/guides/assistant-parameters.mdper DOCS-AIAI-31-006.

This dossier describes the CONSOLIDATED family only. The predecessor deployables advisory-ai-web, advisory-ai-worker and opsmemory-web were retired on 2026-09-07 (SPRINT_20260722_013 AAI-10) after the AAI-9 data-move window of 2026-09-04 put the family live on its own database. What runs is advisoryai-web + advisoryai-worker, two replica roles of one deployable family on stellaops_advisoryai, composed from devops/compose/docker-compose.advisoryai.yml.

Verified against commit 79bf4c6f36 (the retirement) plus live probes taken 2026-09-07 on the running estate. Re-verify with:

docker inspect stellaops-advisoryai-web stellaops-advisoryai-worker --format '{{.Name}} {{.Image}} {{.State.Health.Status}} restarts={{.RestartCount}}'
docker exec stellaops-advisoryai-web sh -lc 'cat /app/buildinfo.json'
rg -n "^  advisoryai-(web|worker):" devops/compose/docker-compose.advisoryai.yml

At that stamp both containers were healthy with RestartCount=0 on images sha256:d423274f669b… and sha256:4fadbb883b7c…, built from gitSha 3c938e875f… with worktreeState clean.

Tenant identity (claim-bound). The family ships one host with an inbound API surface, and it takes the data-isolation tenant from the authenticated stellaops:tenant claim. A caller-supplied tenant is never an isolation key — it is the cross-tenant vector, not a safety property.

  • advisoryai-web (StellaOps.AdvisoryAI.WebService) — envelope-only. Identity resolves exclusively from the gateway-signed identity envelope; InboundIdentityHeaderStripMiddleware strips X-Tenant-Id-style headers at ingress before UseAuthentication(), and the legacy AdvisoryAiHeaderAuthenticationHandler has been deleted (see §7.5). The lifted OpsMemory routes ride that same stack: the /api/v1/opsmemory and /api/advisoryai/v1/opsmemory groups carry .RequireTenant() and every handler isolates on the claim resolved by OpsMemoryTenantResolver. The tenantId query parameter and the RecordDecisionRequest.TenantId body field are optional conflict checks only: disagreeing with the claim is 400 tenant_conflict, and an identity with no tenant is 400 tenant_missing (see §15, “Tenancy contract”).

Landed in Sprint SPRINT_20260712_001 (TEN-1). Before it, OpsMemory endpoints passed the caller’s tenantId straight to PostgresOpsMemoryStore as the sole isolation key and never consulted the claim — on the compose bypass networks that was an unauthenticated cross-tenant read/write. The guarantee is now asserted on the consolidated host by StellaOps.AdvisoryAI.Tests.Integration.AdvisoryAiOpsMemoryTenancyTests (15 facts, all six routes, read and write paths, both roots). The library-level guard in Playbook/PlaybookSuggestionService (throws when handed a blank tenant; the legacy shared-"default" fallback is gone) is defence-in-depth for internal callers of the library — the value the endpoints hand it is always the claim tenant.

1) Goals

2) Pipeline overview

                       +---------------------+
   Concelier/VEX Lens  |  Evidence Retriever |
   Policy Engine ----> |  (vector + keyword) | ---> Context Pack (JSON)
   Zastava runtime     +---------------------+
                               |
                               v
                        +-------------+
                        | Prompt      |
                        | Assembler   |
                        +-------------+
                               |
                               v
                        +-------------+
                        | Guarded LLM |
                        | (local/host)|
                        +-------------+
                               |
                               v
                        +-----------------+
                        | Citation &     |
                        | Validation      |
                        +-----------------+
                               |
                               v
                        +----------------+
                        | Output cache   |
                        | (hash, bundle) |
                        +----------------+

3) Retrieval & context

Retriever requests and results are trimmed/normalized before hashing; metadata (counts, provenance keys) is returned for downstream guardrails. Unit coverage ensures deterministic ordering and flag handling.

All context references include content_hash and source_id enabling verifiable citations.

4) Guardrails

5) Deterministic tooling

6) Output persistence

7) Profiles & sovereignty

7.5) Authentication & authorization (Sprint SPRINT_20260430_002)

Audit finding A2 (docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md): the previously registered AdvisoryAiHeaderAuthenticationHandler accepted raw X-User-Id, X-Tenant-Id, and X-StellaOps-Scopes headers as authoritative identity. That handler has been deleted as a security hardening; the current contract is gateway-envelope-only.

7.6) Outbound service-to-service auth (Authority client_credentials)

Inbound auth (§7.5) is envelope-only. Outbound calls from AdvisoryAI to other internal services are a separate concern handled by an OAuth client_credentials bearer, configured under AdvisoryAi:Authority and wired in Program.cs:

Unified-search live adapter targets and provenance

Each live adapter calls one route on one service, and a wrong target does not fail a query — the adapter catches the failure and answers from its committed snapshot, so the corpus reads as healthy while it is a fixture. That is what let three separate upstream defects run unnoticed for a month (SPRINT_20260722_013 AAI-15).

AdapterRouteServiceScopeNotes
FindingsSearchAdapterGET /api/v2/security/findingsfindings-web (findings.stella-ops.local)findings:readMapSecurityReadModelEndpoints has exactly one live caller, StellaOps.Findings.WebService. The projection’s identifier field is vulnerabilityId, not cveId (FND-25), so the mapper reads that first.
PolicySearchAdapterGET /api/v1/authority/effective-policies?enabledOnly=false&includeExpired=true&limit=100policy-enginepolicy:readPolicy Engine maps no bulk gate-decision list; the previous /api/v1/gates/decisions matched /{bomRef} with bomRef = "decisions". The adapter states every filter (AAI-17) because its mapper renders disabled and expired enforcement, which the route’s own defaults would exclude.
VexSearchAdapterGET /api/vulnerabilities/v1/corpus/publications/current, then the publication’s consensus-inputs section by its declared hrefvulnerabilities-web (vulnerabilities.stella-ops.local)advisory:readPer VULN-G3 D-G3-1 (SPRINT_20260722_008): bulk enumeration is bootstrap-from-publication. The consensus-inputs rows (vulnerability, product, issuer, status, justification, applicability) are the VEX inputs behind the hub consensus; the adapter streams at most 1,000 of them per refresh, validates the section’s format version and self-declared header, and reports corpus_publication_not_ready as a state (snapshot, not suppressed) rather than a missing route. The retired target GET /api/v1/canonical (Concelier) paged canonical advisories and called them VEX statements.

Provenance is a first-class surface, not a log line:

Verified-by: UnifiedSearchLiveAdapterIntegrationTests (route targets, the vulnerabilityId mapping, provenance recording, the 404 backoff and its expiry) and UnifiedSearchEndpointsIntegrationTests (the status endpoint’s scope and tenant gates).

8) APIs

All HTTP routes are mapped in src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs (plus the per-feature Endpoints/*.cs). AAI-9 gate 3 adds native /api/advisoryai/v1/** aliases after the entire optional endpoint surface has been composed; every alias retains its handler, HTTP methods, authorization/rate-limit metadata and response behavior. The legacy /v1/advisory-ai/*, /api/v1/chat/*, /v1/search/*, /v1/evidence-packs/*, /v1/runs/*, and /api/v1/opsmemory/* routes remain in the same image for rollback until AAI-10. Gate 9 adds the six OpsMemory handlers at /api/advisoryai/v1/opsmemory/** with suffixed endpoint names and request-native decision Location links. This is source readiness only: the gateway swap and a clean digest-pinned continuation image are still pending. There is no /api/v1/advisory/* surface — that path is historical and does not exist in code. Every business endpoint is authorized by a named policy (§7.5) and rate-limited by the advisory-ai token bucket (30 req/min per X-StellaOps-Client); write paths are wrapped with .Audited(...).

Authorization policies resolve scopes via StellaOpsScopes: advisory-ai:view (View), advisory-ai:operate (Operate, implies View), advisory-ai:admin (Admin, implies Operate). The legacy advisory:run / advisory:explain / advisory:companion / advisory:remediate / advisory:justify strings still appear in some handler-internal EnsureAuthorized checks, but those strings are not registered Authority scopes — modern gateway envelopes carry the advisory-ai:* scopes that the named policies enforce first.

Pipeline & outputs

Explanation & companion

Remediation (Remedy Autopilot)

Policy Studio (Copilot)

Consent, justification, rate limits (VEX-AI)

Chat gateway (/api/v1/chat/*, all Operate)

Runs ledger (/v1/advisory-ai/runs, group View; mutations Operate, audited)

Attestations & evidence packs

LLM adapter passthrough (only when AdvisoryAI:Adapters:Llm:Enabled=true)

Health & infra

Pipeline plan/output responses carry output_hash, input_digest, and citations for verification.

9) Observability

10) Operational controls

11) Hosting surfaces

11.1) Mounted LLM provider plugin loader (signed bundle admission)

MountedLlmProviderRuntimePluginLoader (src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlmProviders/Admission/MountedLlmProviderRuntimePluginLoader.cs, commit a36f672403) discovers, admits, and activates signed per-provider ILlmProviderPlugin bundles from a mounted profile directory and registers the survivors in LlmProviderCatalog so they flow through the existing unified LlmPluginAdapter. Only the AdvisoryAI web service runs the provider loader (the worker does not), and the LLM adapter surface must be enabled (AdvisoryAI:Adapters:Llm:Enabled, default true in the base stack) for mounted providers to be admitted. The loader is fail-closed and de-duplicates by ILlmProviderPlugin.ProviderId (a duplicate is surfaced as rejected, never silently shadowed). A missing/unmounted root is fail-open: AdvisoryAI keeps running with only the built-in providers.

advisoryai-web also maps the canonical pluginized-compose diagnostics: GET /internal/plugins/status returns the loader’s catalog state, and POST /internal/plugins/probe runs a deterministic catalog probe that marks mounted, discovered, admitted, and loaded provider bundles as responded. The probe does not call an external model endpoint.

Admission chain — each bundle is admitted through the shared SignedRuntimePluginAdmission chokepoint (src/__Libraries/StellaOps.Plugin/Security/SignedRuntimePluginAdmission.cs, promoted from AdvisoryAI in commit c1922c1ce5; AdvisoryAI keeps a thin facade that bakes in module advisoryai, contract runtime-bundle.v1, and capability advisoryai:llm-provider):

  1. Manifest binding — id equals the bundle directory name, module is advisoryai, contractVersion is runtime-bundle.v1, the configured profile matches (when non-empty), capability advisoryai:llm-provider is declared, and a well-formed assembly descriptor (relative path + sha256) is present.
  2. Per-assembly SHA-256 + path-traversal guard — the on-disk assembly bytes must hash to the manifest digest; rooted/escaping paths are rejected; the detached <assembly>.sig is enforced to the conventional location so a tampered manifest cannot redirect the verifier.
  3. Detached RSA-PKCS1-SHA256 verification — OfflineDevRsaSha256PluginVerifier with AllowUnsigned=false against the configured trust root. Only then is the ILlmProviderPlugin entry type activated via ActivatorUtilities. An AssemblyLoadContext resolving hook (AdvisoryAiPluginAssemblyResolver) resolves transitive StellaOps.* deps.

Before code activation, two additional host-level gates keep the mount boundary deterministic. A manifest with enabled=false is reported as disabled with zero providers and no assembly load. When RequireReadOnlyBundles=true (the default), writable bundle directories are reported as rejected with zero providers so the compose read-only mount contract is visible in the probe report.

Configuration (AdvisoryAI:LlmProviders:RuntimePlugins, env prefix ADVISORYAI_):

KeyDefaultPurpose
AdvisoryAI:LlmProviders:RuntimePlugins:RootPath/app/plugins/advisoryaiRoot containing profile directories.
AdvisoryAI:LlmProviders:RuntimePlugins:ProfilebaseProfile; the loader resolves provider bundles under <RootPath>/<Profile>/llm-providers.
AdvisoryAI:LlmProviders:RuntimePlugins:TrustRootPath/app/etc/certificates/trust-roots/plugins/advisoryai/cosign.pubTrust-root public key.
AdvisoryAI:LlmProviders:RuntimePlugins:RequireReadOnlyBundlestrueReject writable provider bundle directories; set false only for local diagnostics/tests where temp directories cannot be mounted read-only.

Hardened drop-points: AdvisoryAiLlmAdapterPluginBridge (mounted unified LLM adapter) and AdvisoryAiScmAdapterPluginBridge (the identical SCM twin) both replaced their bare LoadFromAssemblyPath with the same signed admission, and the /v1/advisory-ai/adapters/llm/... ListProviders surface reports rejected bundles.

Bundle / trust-root layout:

PurposeHost pathContainer path
Signed LLM provider bundledevops/plugins/advisoryai/base/llm-providers/<provider-id>/ (manifest.json + <assembly>.dll + <assembly>.dll.sig)/app/plugins/advisoryai/base/llm-providers/<provider-id>
Operator config/registrydevops/etc/plugins/advisoryai//app/etc/plugins/advisoryai
AdvisoryAI plugin trust rootdevops/etc/certificates/trust-roots/plugins/advisoryai/cosign.pub/app/etc/certificates/trust-roots/plugins/advisoryai/cosign.pub
Probe scratchnamed volume advisoryai-plugin-scratch/var/lib/stellaops/plugin-scratch/advisoryai

Bundle producer: devops/build/package-runtime-plugins.ps1 -Module advisoryai -Profile base -SignAdvisoryAiBundles -UseOfflineDevSigner -OfflineDevSigningKeyRoot <installation-custody-root> stages the signed stellaops.advisoryai.llm-provider.ollama bundle under <profile>/llm-providers/. The cryptography library resolves the installation’s existing runtime-plugin purpose key; missing custody fails instead of silently generating a replacement. Explicit enrollment and lost-key rotation follow Signing keys by purpose. The loader already handles multiple providers; staging the remote provider and adapter bundles is the documented follow-up. The exported public cosign.pub is git-ignored under the AdvisoryAI trust-root directory.

Compose mounts: the advisoryai-web definition in devops/compose/docker-compose.advisoryai.yml carries the read-only mounts of devops/plugins/advisoryai/base + the trust root, keeps the LLM adapter enabled, restates the loader defaults (ADVISORYAI__AdvisoryAI__LlmProviders__RuntimePlugins__RootPath/Profile/TrustRootPath) and names the writable scratch volume explicitly. That contract was written onto the continuation key at AAI-9 gate 4 precisely because the predecessor definition it replaced lived in another file, and it is now the only copy: AAI-10 deleted the predecessor key from devops/compose/docker-compose.stella-services.yml on 2026-09-07. An earlier separate mount overlay had already been retired after Linux Compose 2.39 rejected its duplicate imported-service definition.

Tests: focused loader admission coverage includes signed load, missing/unmounted root, bad hash, bad signature, duplicate provider ID, unsupported contract, capability mismatch, disabled manifest, writable mount, and malformed/path traversal cases; every reject path admits zero providers. The internal status/probe endpoints have integration coverage for status output, probe response marking, and plugin ID filtering.

Live runtime probe is pending. The loader, hardened bridges, producer, and canonical Compose mounts are committed, and /internal/plugins/status + /internal/plugins/probe are mapped, but a live canonical-stack acceptance probe against a running advisoryai-web (mount the signed Ollama provider, confirm GET /v1/advisory-ai/adapters/llm/providers and /internal/plugins/* report it admitted/responded) has not yet been recorded.

12) QA harness & determinism (Sprint 110 refresh)

13) Deployment profiles, scaling, and local model inference

14) Controlled conversational interface and tool gating

See docs/modules/advisory-ai/chat-interface.md and docs-archive/product/advisories/13-Jan-2026 - Controlled Conversational Interface.md.

15) OpsMemory (Operational Memory and RAG)

Consolidated from src/OpsMemory/ into src/AdvisoryAI/ (Sprint 213, 2026-03-04). Archived docs: docs-archive/modules/opsmemory/.

Overview

OpsMemory provides a decision ledger for security operations learning. It captures the complete lifecycle of a security decision – from situation context through action taken to eventual outcome – enabling playbook suggestions for future similar situations.

Source layout (post-consolidation)

Key components

ComponentPurpose
SimilarityVectorGenerator50-dimensional feature vectors from CVE, severity, reachability, EPSS/CVSS, component type, context tags
PlaybookSuggestionServiceConfidence-ranked suggestions from historical decisions
OutcomeTrackingServiceRecords decision outcomes for feedback loop
PostgresOpsMemoryStorePostgres storage with array-based cosine similarity (no pgvector dependency)
OpsMemoryChatProviderChat integration for conversational playbook queries
OpsMemoryContextEnricherEnriches AdvisoryAI context packs with operational memory

API surface

MethodPathDescription
POST/api/v1/opsmemory/decisionsRecord a new decision (Write)
GET/api/v1/opsmemory/decisions/{memoryId}Get decision details (Read)
POST/api/v1/opsmemory/decisions/{memoryId}/outcomeRecord outcome (Write)
GET/api/v1/opsmemory/suggestionsGet playbook suggestions (Read)
GET/api/v1/opsmemory/decisionsQuery past decisions (Read)
GET/api/v1/opsmemory/statsGet statistics (Read)

advisoryai-web serves all six on both roots: the legacy /api/v1/opsmemory/** above and the native /api/advisoryai/v1/opsmemory/**. Route names on the native aliases use the .consolidated suffix, so create responses generate a Location under the prefix the caller used. The pairing is asserted route by route – method, endpoint name, authorization policy and audit metadata – by AdvisoryAiOpsMemoryEndpointTests.HostCarriesAllSixLegacyAndNativeRoutes_WithEquivalentNamesAuthAndAuditMetadata.

Route group requires OpsMemoryPolicies.Read; write paths (POST /decisions, POST /decisions/{memoryId}/outcome) additionally require OpsMemoryPolicies.Write and are .Audited(...). Path parameter is memoryId (OpsMemoryEndpoints.MapOpsMemoryEndpoints). Those policy names read ops-memory:read / ops-memory:write from the signed gateway envelope.

Tenancy contract (claim-bound)

Auth-model note, corrected by the retirement (2026-09-07). Until AAI-10 these routes ran on a second host that did not use the §7.5 envelope stack: opsmemory-web authenticated as an Authority resource server (a raw JWT bearer) reachable over the backend bypass networks, so the header strip and envelope authentication §7.5 describes did not apply to it. That host is gone. The routes now run inside advisoryai-web and inherit §7.5 in full — envelope-only identity, the inbound header strip, and UseStellaOpsTenantMiddleware + .RequireTenant() on the groups. The tenancy guarantee below is enforced in the endpoints themselves, so it holds on either root.

The isolation tenant is resolved exclusively from the authenticated stellaops:tenant claim (OpsMemoryTenantResolver → IStellaOpsTenantAccessor, populated by the shared tenant middleware). Caller-supplied tenant input is never an isolation key:

Landed in Sprint SPRINT_20260712_001 (TEN-1); before it, every endpoint passed the caller’s tenantId straight to PostgresOpsMemoryStore as the sole isolation key.

Database

The opsmemory schema now lives in the family’s own database stellaops_advisoryai, created by the consolidated baseline, which carries opsmemory.decisions alongside the AdvisoryAI tables. There is exactly one migration authority: advisoryai-web registers the folded store/playbook/chat services against the same service-owned DSN as every other AdvisoryAI store, without a second migrator, and the OpsMemory pool stays private to the module so the bare NpgsqlDataSource DI slot is free. __Libraries/StellaOps.OpsMemory/Migrations/001_initial_schema.sql is no longer applied by anything: the host that ran it (AddStartupMigrations<PostgresOptions>(...)) was retired on 2026-09-07. The legacy stellaops_platform.opsmemory schema is retained offline as a rollback input until the separately approved destructive drop. Tenant isolation is enforced at the query level (WHERE tenant_id = @tenantId), and the @tenantId the store receives is always the claim tenant — see the tenancy contract above; it is never taken from caller input. Similarity search uses array-stored vectors with in-store cosine similarity — no pgvector dependency.

Connection contract:

Dependencies