diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0f3b354d..7c8446a4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,20 +4,20 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -27,10 +27,10 @@
-
+
-
+
@@ -59,6 +59,9 @@
+
+
+
@@ -112,10 +115,12 @@
-
-
-
-
+
+
+
+
+
+
@@ -131,7 +136,7 @@
-
+
diff --git a/Makefile b/Makefile
index 145096f2..ed49963b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build build-core build-demos prod-dry-run prod-dry-run-agent-server restore-core test test-prod-style test-framework-prod-first test-prime-time test-prime-time-full test-cross-platform test-portable test-multi-env test-all-platforms test-all-platforms-ephemeral ci-verify kernel-gate kernel-gate-tier-b kernel-gate-tier-c kernel-gate-tier-d kernel-gate-tier-e kernel-gate-full application-gate application-gate-tier-a application-gate-tier-b application-gate-tier-c application-gate-tier-d application-gate-full composition-mesh-gate composition-mesh-gate-tier-a composition-mesh-gate-tier-b composition-mesh-gate-tier-c composition-mesh-gate-tier-d composition-mesh-gate-full dependency-boundary-gate ship-gate ship-gate-tier-a ship-gate-tier-b ship-gate-tier-c ship-gate-tier-d ship-gate-full ops-gate ops-gate-tier-a ops-gate-tier-b ops-gate-tier-c ops-gate-tier-d ops-gate-tier-e ops-gate-full security-gate security-gate-tier-a security-gate-tier-b security-gate-tier-c security-gate-tier-d security-gate-tier-e security-gate-full rc-gate rc-gate-tier-a rc-gate-tier-b rc-gate-tier-c rc-gate-tier-d rc-gate-tier-e rc-gate-full perf-gate perf-gate-tier-a perf-gate-tier-b perf-gate-tier-c perf-gate-tier-d perf-gate-full compat-gate compat-gate-tier-a compat-gate-tier-b compat-gate-tier-c compat-gate-full dr-gate dr-gate-tier-a dr-gate-tier-b dr-gate-tier-c dr-gate-full waterproofing-gate-full nexo-ready-gate bootstrap-mesh-lab-env validate-safe review-summary clean-test-artifacts test-readiness-gate release-preflight release-gate release-dispatch release-staging verify-staging release-staging-and-verify verify-external-product-shape mesh-lab-e2e mesh-lab-e2e-workers mesh-lab-e2e-deep mesh-lab-e2e-stress mesh-lab-up mesh-lab-verify mesh-lab-verify-deep mesh-lab-verify-entitlements mesh-lab-verify-governance mesh-lab-verify-director-cli mesh-lab-verify-persistence mesh-lab-verify-network-negative mesh-lab-verify-post-stress mesh-lab-stress mesh-lab-down test-mesh-lab
+.PHONY: build build-core build-demos prod-dry-run prod-dry-run-agent-server restore-core test test-prod-style test-framework-prod-first test-prime-time test-prime-time-full test-cross-platform test-portable test-multi-env test-all-platforms test-all-platforms-ephemeral ci-verify meai-pipeline-gate kernel-gate kernel-gate-tier-b kernel-gate-tier-c kernel-gate-tier-d kernel-gate-tier-e kernel-gate-full application-gate application-gate-tier-a application-gate-tier-b application-gate-tier-c application-gate-tier-d application-gate-full composition-mesh-gate composition-mesh-gate-tier-a composition-mesh-gate-tier-b composition-mesh-gate-tier-c composition-mesh-gate-tier-d composition-mesh-gate-full dependency-boundary-gate ship-gate ship-gate-tier-a ship-gate-tier-b ship-gate-tier-c ship-gate-tier-d ship-gate-full ops-gate ops-gate-tier-a ops-gate-tier-b ops-gate-tier-c ops-gate-tier-d ops-gate-tier-e ops-gate-full security-gate security-gate-tier-a security-gate-tier-b security-gate-tier-c security-gate-tier-d security-gate-tier-e security-gate-full rc-gate rc-gate-tier-a rc-gate-tier-b rc-gate-tier-c rc-gate-tier-d rc-gate-tier-e rc-gate-full perf-gate perf-gate-tier-a perf-gate-tier-b perf-gate-tier-c perf-gate-tier-d perf-gate-full compat-gate compat-gate-tier-a compat-gate-tier-b compat-gate-tier-c compat-gate-full dr-gate dr-gate-tier-a dr-gate-tier-b dr-gate-tier-c dr-gate-full waterproofing-gate-full nexo-ready-gate bootstrap-mesh-lab-env validate-safe review-summary clean-test-artifacts test-readiness-gate release-preflight release-gate release-dispatch release-staging verify-staging release-staging-and-verify verify-external-product-shape mesh-lab-e2e mesh-lab-e2e-workers mesh-lab-e2e-deep mesh-lab-e2e-stress mesh-lab-up mesh-lab-verify mesh-lab-verify-deep mesh-lab-verify-entitlements mesh-lab-verify-governance mesh-lab-verify-director-cli mesh-lab-verify-persistence mesh-lab-verify-network-negative mesh-lab-verify-post-stress mesh-lab-stress mesh-lab-down test-mesh-lab
# External product shape: packed Nexo.* feed → authored brick + thin host + HTTP client (no repo refs).
verify-external-product-shape:
@@ -177,6 +177,11 @@ testing-strategy-gate:
dependency-boundary-gate:
bash scripts/dependency-boundary-gate.sh
+# MEAI governed pipeline + VectorData RAG architecture tests (net8).
+meai-pipeline-gate:
+ dotnet test src/Nexo.Tests.AI.Pipeline/Nexo.Tests.AI.Pipeline.csproj -f net8.0 -c Release --nologo \
+ --blame-hang-timeout 120s --blame-hang-dump-type none
+
kernel-gate:
dotnet build Nexo.Runtime.sln -v minimal
dotnet build src/Nexo.Tests.Infrastructure/Nexo.Tests.Infrastructure.csproj -v minimal
@@ -186,6 +191,7 @@ kernel-gate:
NEXO_ALLOW_MOCK=1 dotnet test src/Nexo.Tests.Infrastructure/Nexo.Tests.Infrastructure.csproj -f net8.0 --no-build \
--filter "FullyQualifiedName~PipelineTemplateValidatorTests|FullyQualifiedName~PipelineLifecycleE2ETests" \
--blame-hang-timeout 120s --blame-hang-dump-type none
+ $(MAKE) meai-pipeline-gate
@if [ "$${KERNEL_GATE_PRODSTYLE:-0}" = "1" ]; then $(MAKE) test-prod-style; fi
@if [ "$${KERNEL_GATE_MESH:-0}" = "1" ]; then $(MAKE) mesh-lab-verify; fi
diff --git a/Nexo.Kernel.sln b/Nexo.Kernel.sln
index 856fc836..2a7f05c7 100644
--- a/Nexo.Kernel.sln
+++ b/Nexo.Kernel.sln
@@ -61,6 +61,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Spatial.Platform.XREAL
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Spatial.Platform.VisionPro", "src\Nexo.Spatial.Platform.VisionPro\Nexo.Spatial.Platform.VisionPro.csproj", "{AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.AI.Pipeline", "src\Nexo.AI.Pipeline\Nexo.AI.Pipeline.csproj", "{8115FD2E-46AF-4A38-914D-B4D00AEE5414}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Tests.AI.Pipeline", "src\Nexo.Tests.AI.Pipeline\Nexo.Tests.AI.Pipeline.csproj", "{0B55F79C-992F-4664-AF04-DFDD55D9812F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -407,6 +411,30 @@ Global
{AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}.Release|x64.Build.0 = Release|Any CPU
{AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}.Release|x86.ActiveCfg = Release|Any CPU
{AF198EA0-BF30-4C9A-BED5-43E0C3F047F1}.Release|x86.Build.0 = Release|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x64.Build.0 = Debug|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Debug|x86.Build.0 = Debug|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x64.ActiveCfg = Release|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x64.Build.0 = Release|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x86.ActiveCfg = Release|Any CPU
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414}.Release|x86.Build.0 = Release|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x64.Build.0 = Debug|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Debug|x86.Build.0 = Debug|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x64.ActiveCfg = Release|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x64.Build.0 = Release|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x86.ActiveCfg = Release|Any CPU
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -440,5 +468,7 @@ Global
{075FBDC0-DF1B-46B2-A74E-70C1A6CCF6B1} = {31297073-3FD6-401C-A313-89BE7C850D6B}
{B3BA94D1-49D6-4781-8BF7-7939560DD859} = {31297073-3FD6-401C-A313-89BE7C850D6B}
{AF198EA0-BF30-4C9A-BED5-43E0C3F047F1} = {31297073-3FD6-401C-A313-89BE7C850D6B}
+ {8115FD2E-46AF-4A38-914D-B4D00AEE5414} = {31297073-3FD6-401C-A313-89BE7C850D6B}
+ {0B55F79C-992F-4664-AF04-DFDD55D9812F} = {31297073-3FD6-401C-A313-89BE7C850D6B}
EndGlobalSection
EndGlobal
diff --git a/Nexo.Runtime.sln b/Nexo.Runtime.sln
index 5185df0d..93053a92 100644
--- a/Nexo.Runtime.sln
+++ b/Nexo.Runtime.sln
@@ -39,6 +39,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.BackgroundAgents.HostR
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Hosting", "src\Nexo.Hosting\Nexo.Hosting.csproj", "{852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.AI.Pipeline", "src\Nexo.AI.Pipeline\Nexo.AI.Pipeline.csproj", "{50497523-BE3C-474B-B18F-484FADF6CE30}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Tests.AI.Pipeline", "src\Nexo.Tests.AI.Pipeline\Nexo.Tests.AI.Pipeline.csproj", "{DF6026C1-BA7D-4B91-9264-B505DF100551}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -253,6 +257,30 @@ Global
{852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}.Release|x64.Build.0 = Release|Any CPU
{852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}.Release|x86.ActiveCfg = Release|Any CPU
{852A6EFA-21A3-47A3-8EC8-35A0B06CA36A}.Release|x86.Build.0 = Release|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x64.Build.0 = Debug|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Debug|x86.Build.0 = Debug|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|Any CPU.Build.0 = Release|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x64.ActiveCfg = Release|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x64.Build.0 = Release|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x86.ActiveCfg = Release|Any CPU
+ {50497523-BE3C-474B-B18F-484FADF6CE30}.Release|x86.Build.0 = Release|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x64.Build.0 = Debug|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Debug|x86.Build.0 = Debug|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x64.ActiveCfg = Release|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x64.Build.0 = Release|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x86.ActiveCfg = Release|Any CPU
+ {DF6026C1-BA7D-4B91-9264-B505DF100551}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -275,5 +303,7 @@ Global
{215BF0F8-C5B4-4310-BDFF-E6930BDB5E6E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{8DB1D6CC-D71C-4060-B481-2C1362ACB719} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{852A6EFA-21A3-47A3-8EC8-35A0B06CA36A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {50497523-BE3C-474B-B18F-484FADF6CE30} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {DF6026C1-BA7D-4B91-9264-B505DF100551} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/Nexo.sln b/Nexo.sln
index a6e26022..344257f9 100644
--- a/Nexo.sln
+++ b/Nexo.sln
@@ -139,6 +139,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Provenance.Graph.Tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Provenance.Demo", "tools\Nexo.Provenance.Demo\Nexo.Provenance.Demo.csproj", "{1D8336EA-841C-41D3-8004-B961477D2160}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.AI.Pipeline", "src\Nexo.AI.Pipeline\Nexo.AI.Pipeline.csproj", "{AE1A6E76-6D57-4A38-9EB7-989083F7DD92}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Tests.AI.Pipeline", "src\Nexo.Tests.AI.Pipeline\Nexo.Tests.AI.Pipeline.csproj", "{41A42E00-05DC-40E7-9BC5-61B145A4614D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -905,6 +909,30 @@ Global
{1D8336EA-841C-41D3-8004-B961477D2160}.Release|x64.Build.0 = Release|Any CPU
{1D8336EA-841C-41D3-8004-B961477D2160}.Release|x86.ActiveCfg = Release|Any CPU
{1D8336EA-841C-41D3-8004-B961477D2160}.Release|x86.Build.0 = Release|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x64.Build.0 = Debug|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Debug|x86.Build.0 = Debug|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x64.ActiveCfg = Release|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x64.Build.0 = Release|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x86.ActiveCfg = Release|Any CPU
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92}.Release|x86.Build.0 = Release|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x64.Build.0 = Debug|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Debug|x86.Build.0 = Debug|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x64.ActiveCfg = Release|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x64.Build.0 = Release|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x86.ActiveCfg = Release|Any CPU
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -974,6 +1002,8 @@ Global
{B42C5AE2-1208-49CB-A3E8-DF58B1088052} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
{935D3EE2-2EE3-4651-BF2D-A75151FE1B81} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
{1D8336EA-841C-41D3-8004-B961477D2160} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
+ {AE1A6E76-6D57-4A38-9EB7-989083F7DD92} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
+ {41A42E00-05DC-40E7-9BC5-61B145A4614D} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {12345678-1234-1234-1234-123456789ABC}
diff --git a/docs/governed-pipeline.md b/docs/governed-pipeline.md
new file mode 100644
index 00000000..6e229268
--- /dev/null
+++ b/docs/governed-pipeline.md
@@ -0,0 +1,54 @@
+# Governed MEAI pipeline
+
+Nexo’s default model path is **Microsoft.Extensions.AI (MEAI)** with a fixed governance stack.
+Legacy `IProviderFactory` chat via `ProviderBackedModel` remains available as an **opt-out** only.
+
+## Feature flag
+
+| Source | Enable | Disable |
+|--------|--------|---------|
+| Default (Phase 6+) | **on** | — |
+| Config | `Nexo:UseMeaiPipeline=true` | `false` / `0` |
+| Env | `NEXO_USE_MEAI_PIPELINE=1\|true` | `0\|false` |
+| Hosting | `NexoHostingOptions.UseMeaiPipeline = true` | `= false` |
+
+When enabled, Hosting Phase 13b registers keyed/routed clients and Phase 13 uses `MeaiBackedModel` as the agentic leaf under `HotSwappableModel`.
+
+## Stack order (do not reorder)
+
+Per-target clients use `UseNexoGovernance(targetKey)`:
+
+1. **PolicyGate** — allow/deny target (local allowed; cloud deny unless allow-listed)
+2. **Sanitizing** — PII/secret disposition per target (`Pass` local; `BlockOnSecretRedactOnPii` cloud)
+3. **Auditing** — counts/categories/latency only; never content
+4. **Provider** — Ollama HTTP, LLamaSharp (`local:onnx`), or Bedrock MEAI
+
+The **router** (`RoutingChatClient`) sits outside per-target stacks and is itself wrapped in `AuditingChatClient` (`router:default`).
+
+Embeddings use the same AsyncLocal-aware nesting: **Sanitizing → Auditing → generator**.
+
+## Target keys
+
+| Key | Role |
+|-----|------|
+| `local:ollama` | Local Ollama HTTP |
+| `local:onnx` | Local LLamaSharp GGUF (product key; not ONNX Runtime) |
+| `cloud:bedrock:fast\|balanced\|heavy` | AWS Bedrock (policy allow-listed when Bedrock enabled) |
+
+Capability hint: `ChatOptions.AdditionalProperties["nexo.route.capability"]` = `fast` / `balanced` / `heavy`.
+
+Raw provider types (`OllamaHttpChatClient`, Bedrock SDK client, LLamaSharp session) are **never** registered in DI.
+
+## RAG
+
+- Default `IRAGService` → `MeaiVectorDataRagAdapter` over `VectorDataRagService` (in-process VectorData store + governed embeddings).
+- Reindex: call `VectorDataRagService.ReindexAsync` from Hosting DI (CLI `reindex-meai` lands on an `application/*` PR per layer-boundary rules).
+- Legacy `RAGService` / store types remain in-tree for opt-out / migration but are not the Hosting default.
+
+## Operator notes
+
+- Bedrock: `Nexo:Meai:Bedrock:Enabled` + region/model ids; credentials via the default AWS chain (same as DynamoDB ingress).
+- Opting out restores `ProviderBackedModel` for `IModel` but keeps VectorData `IRAGService` unless Hosting phases are customized.
+- Architecture tests: `make meai-pipeline-gate` (`src/Nexo.Tests.AI.Pipeline`).
+
+See also: `docs/meai-migration-notes.md`.
diff --git a/docs/meai-migration-notes.md b/docs/meai-migration-notes.md
new file mode 100644
index 00000000..f6b197b3
--- /dev/null
+++ b/docs/meai-migration-notes.md
@@ -0,0 +1,445 @@
+# MEAI Migration Notes — Phase 0 Discovery
+
+**Status:** Phase 0 complete (read-only discovery)
+**Date:** 2026-07-14
+**Repo TFM today:** host/library projects are **`net8.0`** (SDK pinned to `9.0.100` in `global.json`). Plan asks for **.NET 9**; Phase 1 should introduce `Nexo.AI.Pipeline` as `net9.0` (or dual-target) and confirm host upgrade scope separately.
+**MEAI today:** **none** — no `Microsoft.Extensions.AI*` packages, no `IChatClient`.
+
+This document is the map for Phases 1–6. Later phases must update this file when discoveries invalidate assumptions.
+
+---
+
+## 1. Model invocation today
+
+### Primary seam: `IModel` → `IProviderFactory`
+
+```
+Agents / ToolCallingAgent / Orchestration
+ │
+ ▼
+ IModel.CompleteAsync(ModelInput)
+ │
+ OrchestrationRuntimeModelDecorator
+ → HotSwappableModel
+ → ProviderBackedModel
+ │
+ ▼
+ IProviderFactory.ExecuteLLMAsync / Vision / Video
+ │
+ ├── openai / openai_compat / azure → HttpClient chat completions
+ ├── ollama → OllamaProvider → POST /api/chat
+ ├── local → LocalModelProvider (LLamaSharp + GGUF)
+ ├── video → VIDEO_SERVICE_URL HTTP
+ └── mock / offline / echo → MockScaffoldingResponder
+```
+
+| Type | Path | Role |
+|------|------|------|
+| `IModel` | `src/Nexo.Abstractions/IModel.cs` | Agent-facing completion API |
+| `ModelInput` / `ModelOutput` | `src/Nexo.Abstractions/ModelInput.cs`, `ModelOutput.cs` | Message / completion DTOs |
+| `IProviderFactory` | `src/Nexo.Infrastructure/Execution/IProviderFactory.cs` | Gateway for all provider HTTP / local calls |
+| `ProviderFactory` | `src/Nexo.Infrastructure/Execution/ProviderFactory.cs` | **Central invoker** — OpenAI, Azure, openai_compat, Ollama, local, video, mock |
+| `ProviderBackedModel` | `src/Nexo.Infrastructure/Execution/Models/ProviderBackedModel.cs` | `IModel` → parses `nexo.model.provider=` / `nexo.model.name=` → factory |
+| `HotSwappableModel` | `src/Nexo.Infrastructure/Execution/Models/HotSwappableModel.cs` | Runtime swap; respects `NEXO_MODEL_PROVIDER` |
+| `OrchestrationRuntimeModelDecorator` | `src/Nexo.Orchestration/Models/OrchestrationRuntimeModelDecorator.cs` | Outer `IModel`; injects orchestration runtime spec |
+| `OrchestrationHotSwappableModel` | `src/Nexo.Orchestration/Models/OrchestrationHotSwappableModel.cs` | Primary/fallback swap (orchestration layer) |
+| `AgentScopedModel` | `src/Nexo.Orchestration/Models/AgentScopedModel.cs` | Per-agent provider/name directives |
+| `OllamaProvider` | `src/Nexo.Infrastructure/Execution/Ollama/OllamaProvider.cs` | Ollama HTTP client (`/api/chat`, health, tags) |
+| `LocalModelProvider` | `src/Nexo.Infrastructure/Execution/LocalModelProvider.cs` | **In-process LLamaSharp GGUF** (`NEXO_LOCAL_MODEL_PATH`) — **not ONNX Runtime** |
+| `OpenAiCompatibleEndpoint` | `src/Nexo.Infrastructure/Execution/OpenAiCompatibleEndpoint.cs` | URL normalization for `/v1/chat/completions` |
+| `AdaptiveProviderFactory` | `src/Nexo.Infrastructure/Execution/AdaptiveProviderFactory.cs` | Chooses provider via `ILoadPolicy` |
+| `PreferenceLoadPolicy` / `ILoadPolicy` | `src/Nexo.Infrastructure/Execution/LoadPolicy/` | Local-vs-cloud preference (`NEXO_LOAD_PREFERENCE`) |
+| `MockScaffoldingResponder` | `src/Nexo.Infrastructure/Execution/MockScaffoldingResponder.cs` | Deterministic mock/offline responses |
+| `OllamaEphemeralLifecycle` | `src/Nexo.Infrastructure/Execution/Ephemeral/OllamaEphemeralLifecycle.cs` | Ephemeral Docker Ollama per session |
+
+**Important correction vs plan wording:** “ONNX / offline target” in docs/`BackendType.OnnxRuntime` is largely a **placeholder**. Real offline inference is **LLamaSharp + GGUF** via `LocalModelProvider`. Phase 1 should wrap **`local` (LLamaSharp)** as `local:onnx` *only if* product naming requires that key; prefer key **`local:llamasharp`** (or map `local:onnx` → LLamaSharp with a comment) so policy docs stay honest.
+
+### Secondary seam: NCR `IModelServingBackend`
+
+| Type | Path | Role |
+|------|------|------|
+| `IModelServingBackend` | `src/Nexo.Core.Application/NodeCapabilityRuntime/Ports/IModelServingBackend.cs` | `RunInferenceAsync`, load/unload/pull |
+| `OllamaModelServingBackend` | `src/Nexo.Infrastructure/NodeCapabilityRuntime/Backends/OllamaModelServingBackend.cs` | Desktop NCR → Ollama HTTP |
+| `NullModelServingBackend` | `src/Nexo.Infrastructure/NodeCapabilityRuntime/Backends/NullModelServingBackend.cs` | No-op; reports `BackendType.OnnxRuntime` |
+| `BackendType` | `src/Nexo.Core.Application/NodeCapabilityRuntime/Models/BackendType.cs` | `Ollama`, `LlamaCppMobile`, `OnnxRuntime` (only Ollama implemented) |
+| `NodeCapabilityRuntime` | `src/Nexo.Infrastructure/NodeCapabilityRuntime/NodeCapabilityRuntime.cs` | Model selection / ensure-ready |
+| `NcrAgenticBrickEngine` | `src/Nexo.Infrastructure/Execution/Agentic/NcrAgenticBrickEngine.cs` | Agentic bricks → NCR lifecycle |
+
+### Capability / job routing (not chat-client routing)
+
+| Type | Path | Role |
+|------|------|------|
+| `ExecutionTarget` | `src/Nexo.Core.Application/Execution/Routing/ExecutionTarget.cs` | Local vs remote job target |
+| `ICapabilityRouter` / `NcrCapabilityRouter` | Application port + `src/Nexo.Infrastructure/Execution/Routing/NcrCapabilityRouter.cs` | Local / peer / RunPod |
+| `ProviderFactoryLocalExecutor` | `src/Nexo.Infrastructure/Execution/Routing/ProviderFactoryLocalExecutor.cs` | Local jobs → `ExecuteLLMAsync` |
+| `IEndpointRouter` / `CompositeEndpointRouter` | Abstractions + Orchestration | Agent **transport** endpoints (not LLM providers) |
+
+### Direct `IProviderFactory` callers (bypass `IModel`)
+
+| Type | Path |
+|------|------|
+| `ProviderGeneratorModel` | `src/Nexo.Infrastructure/Adaptation/Generation/ProviderGeneratorModel.cs` |
+| `ProviderCompositionGeneratorModel` | `src/Nexo.Infrastructure/Certification/Composition/ProviderCompositionGeneratorModel.cs` |
+| `ContentGenerator` | `src/Nexo.Infrastructure/Export/ContentGenerator.cs` |
+| `OWASPScannerBrick` | `src/Nexo.Bricks.Owasp/Security/OWASPScannerBrick.cs` |
+| `ProviderFactoryLocalExecutor` | (above) |
+
+### Explicitly absent
+
+| Search | Result |
+|--------|--------|
+| AWS Bedrock | **Zero** code references |
+| `Microsoft.Extensions.AI` / `IChatClient` | **Zero** |
+| Anthropic HTTP | Domain enum only; not in `ProviderFactory` |
+| Real ONNX Runtime GenAI | Enum/placeholder only |
+
+---
+
+## 2. Sanitization / PII / secret filtering
+
+| Type | Path | Role |
+|------|------|------|
+| `ICloudSanitizationProxy` / `CloudSanitizationProxy` | `src/Nexo.BackgroundAgents/Trust/` | Sanitizes outbound prompts before cloud; PII block/redact + taxonomy |
+| `SanitizingProviderFactory` | `src/Nexo.BackgroundAgents/Trust/SanitizingProviderFactory.cs` | **`IProviderFactory` decorator** — runs proxy then delegates |
+| `OutgoingContext` / `SanitizationResult` | same folder | Input/result models |
+| `SanitizationAuditEntry` (+ DTO) | Trust + `src/Nexo.Core.Application/Trust/Ports/` | Redaction audit row (counts/categories — no raw secrets by design) |
+| `ISanitizationAuditLog` | Trust | `LogRedaction` / `GetRecent` |
+| `ISensitiveContentFilter` / `SensitiveContentFilter` | `src/Nexo.BackgroundAgents/WebSearch/` | Regex email/phone/SSN/API-key/CC; `RedactPii` / `ShouldBlockQuery` |
+| `IDataTaxonomy` / `DataTaxonomy` + JSON | `src/Nexo.BackgroundAgents/DataSensitivity/` | Data-type → sensitivity (e.g. api-keys → Secret) |
+| `IDataSensitivityRegistry` / levels | same | Public→TopSecret; drives exfiltration + RAG filters |
+| `DataExfiltrationPolicy` | `src/Nexo.BackgroundAgents/Security/DataExfiltrationPolicy.cs` | Tool-call policy: blocks LLM/search when sensitivity forbids |
+| `SupportDiagnosticsExporter` | `application/src/Nexo.API/Security/` | Redacts sensitive **config** keys (not LLM prompts) |
+
+**Behavior today (CloudSanitizationProxy):** air-gapped → pass-through; else PII detected → **block**; filterable PII → **redact**; taxonomy may further constrain. Not yet policy-pack-driven per destination target (Phase 2 must make this policy-driven: redact / block / pass by target).
+
+**Noise:** `SanitizeXmlName`, `SanitizeIdentifier`, Unity `SanitizeClassName` — unrelated to LLM egress.
+
+---
+
+## 3. Audit sinks
+
+### Barrier audit pipeline
+
+| Type | Path | Role |
+|------|------|------|
+| `IBarrierAuditSink` / `IBarrierAuditLog` | `src/Nexo.Abstractions/Barriers/` | Pluggable barrier audit |
+| `StructuredBarrierAuditLog` | `src/Nexo.Runtime/Barriers/` | Fans out to all sinks |
+| `FileBarrierAuditSink` | `src/Nexo.Runtime/Barriers/Sinks/` | `Nexo:Audit:Sinks` contains `File` |
+| `StructuredLogBarrierAuditSink` | same | ILogger sink |
+| `NoOpBarrierAuditSink` | same | Default / discard |
+| Registration | `src/Nexo.Runtime/RuntimeServiceCollectionExtensions.cs` → `AddBarrierAuditSinks` | Bound from `Nexo:Audit:*` |
+
+### Trust / data-decision audit (LLM sanitization lives here)
+
+| Type | Path | Role |
+|------|------|------|
+| `IDataDecisionAuditLog` | `src/Nexo.Core.Application/Trust/Ports/` | Unified: sanitization, boundary, classification, etc. |
+| `DataDecisionAuditLog` / `LiteDbDataDecisionAuditLog` | `src/Nexo.BackgroundAgents/Trust/` | In-memory or LiteDB (`NEXO_TRUST_AUDIT_DB`) |
+| CLI `TrustCommand.AuditAsync` | `application/src/Nexo.CLI/Commands/TrustCommand.cs` | Export/show audit |
+
+### Related (not model-call audit)
+
+- `IAdaptationAuditLog` / `LiteDbAdaptationAuditLog` — adaptation decisions
+- GameDirector `AuditRecord` / MCP `GetAuditTrailTool` — commercial activity feed
+
+**Phase 2 implication:** `AuditingChatClient` should write to **`IDataDecisionAuditLog` / sanitization audit** (and optionally emit a barrier correlation id). Do not invent a third audit store; barrier sinks are for barrier lifecycle, not model invocations.
+
+---
+
+## 4. Policy packs — load & evaluate
+
+### Trust policy packs (observation / regulated packs)
+
+| Type | Path | Role |
+|------|------|------|
+| `ITrustPolicyPackRegistry` / `TrustPolicyPackRegistry` | Ports + `src/Nexo.Infrastructure/Trust/` | Load `*.json`, activate pack |
+| `TrustPolicyPack` (+ info/status/rules models) | `src/Nexo.Core.Application/Trust/Models/` | Pack schema |
+| On-disk packs | `config/trust-packs/{strict-enterprise,internal-only,air-gapped,active-pack}.json` | Pack content + activation |
+| `IAccessBoundary` / `AccessBoundary` | Infrastructure Trust | `ApplyPolicyPack`; observation gates |
+| `IObservationGate` / `ObservationGate` | same | `ShouldObserve` from active boundary |
+| CLI | `TrustCommand` pack list/describe/apply | Operator UX |
+
+**Env:** `NEXO_TRUST_POLICY_PACKS_PATH`, `NEXO_TRUST_ENABLED`, `NEXO_TRUST_AUDIT_DB`.
+
+### Tool-call policy engine (separate from packs)
+
+| Type | Path | Role |
+|------|------|------|
+| `IPolicy` | `src/Nexo.Abstractions/IPolicy.cs` | Approve/deny tool calls |
+| `PolicyEngine` | `src/Nexo.Runtime/PolicyEngine.cs` | Evaluate all `IPolicy`, sign deltas |
+| `BackgroundAgentPolicyEngineFactory` | `src/Nexo.BackgroundAgents/Security/` | Builds engine with `DataExfiltrationPolicy` |
+| `AllowAllPolicy`, path/sandbox policies | `src/Nexo.Policies/`, `src/Nexo.Policies.Dev/` | Built-in tool policies |
+
+**No `PolicyGate` type exists today.** Phase 2 `PolicyGateChatClient` is new; it should consult trust/data-classification → allowed execution targets (extend packs or add a new pack section for target keys — design in Phase 2).
+
+### Trust tiers (mesh / fleet — not RAG trust tags)
+
+| Type | Path | Role |
+|------|------|------|
+| `PeerTrustTier` | `src/Nexo.Core.Application/Mesh/Models/` | Unknown/Untrusted/Trusted |
+| `MeshTrustPolicyConfiguration` | Mesh | `NEXO_MESH_TRUST_POLICY` |
+| Fleet trust | commercial Fleet contracts | Placement eligibility |
+
+RAG sensitivity is **`IDataSensitivityRegistry` levels**, not `PeerTrustTier`. Phase 5 “trust-tier tag” maps to sensitivity level names.
+
+---
+
+## 5. RAG / embedding / vector storage
+
+| Type | Path | Role |
+|------|------|------|
+| `IEmbeddingGenerator` | `src/Nexo.BackgroundAgents/RAG/IEmbeddingGenerator.cs` | **Nexo-local** `GenerateAsync → float[]` — **name collision with MEAI** |
+| `TokenEmbeddingGenerator` | same | Deterministic bag-of-words (dim 64 from `NexoDefaults`) |
+| `IVectorStore` / `InMemoryVectorStore` | same | Default DI store |
+| `SqliteVectorStore` | same | Implemented + tested; **not** registered in `AddBackgroundAgentsRAG` |
+| `IRAGService` / `RAGService` | same | Embed + index/search façade |
+| `IKnowledgeBaseIndexer` / `KnowledgeBaseIndexer` | same | File → RAG indexing |
+| `RAGTool` | same | Agent tool `rag_search` |
+| `RAGConfig` | `src/Nexo.BackgroundAgents/Configuration/RAGConfig.cs` | Docs mention sqlite/postgres/qdrant; only in-memory wired |
+| `DecompositionRetriever` | `src/Nexo.Orchestration/Architect/` | Keyword “RAG” over examples — **no embeddings** |
+| Tests | `src/Nexo.Tests.BackgroundAgents/RAG/*` | Coverage for stores, embeddings, tool |
+
+**DI:** `AddBackgroundAgentsRAG()` → `TokenEmbeddingGenerator`, `InMemoryVectorStore`, `RAGService`, `KnowledgeBaseIndexer` (kernel Phase 11 when `IncludeBackgroundAgentRag`).
+
+**Phase 5 note:** rename or alias Nexo’s `IEmbeddingGenerator` when adopting MEAI’s `IEmbeddingGenerator>` to avoid type collisions (qualify namespaces).
+
+---
+
+## 6. DI registration points (CLI + API)
+
+### Hosts
+
+| Host | Project | Entry |
+|------|---------|-------|
+| CLI | `application/src/Nexo.CLI/` | `Program.cs` → `AddNexoRuntimeRouting` + `AddNexo()` |
+| API | `application/src/Nexo.API/` | `Program.cs` → same + API-only ingress (SNS/DynamoDB) |
+
+No `Startup.cs`. Feature flags are **Options + env**, not Microsoft.FeatureManagement.
+
+### Shared composition root
+
+| File | Role |
+|------|------|
+| `src/Nexo.Hosting/NexoServiceCollectionExtensions.cs` | `AddNexo` / `AddNexoProfile` |
+| `src/Nexo.Hosting/NexoKernelRegistrar.cs` + `.Phases.cs` | Ordered phases |
+| `src/Nexo.Hosting/ModuleSelection.cs` | Profile gates (`IncludeBackgroundAgentRag`, `IncludeTrustServices`, …) |
+| `src/Nexo.Hosting/NexoHostingOptions.cs` | `TrustEnabled`, hosted-agent flags, etc. |
+
+### AI-relevant kernel phases
+
+| Phase | What |
+|-------|------|
+| **11** | `AddBackgroundAgents` + optional `AddBackgroundAgentsRAG` |
+| **13** | `HotSwappableModel` + `IModel` = `OrchestrationRuntimeModelDecorator` |
+| **14** | Optional `IEphemeralModelLifecycle` → Ollama ephemeral |
+| **15** | Trust + `IProviderFactory` 3-way branch (adaptive / sanitizing / plain) |
+
+### Other DI
+
+| Extension | Path |
+|-----------|------|
+| `AddTrustServices` | `src/Nexo.BackgroundAgents/ServiceCollectionExtensions.cs` |
+| `AddAccessBoundary` | `src/Nexo.Infrastructure/Trust/Sdk/Extensions/TrustServiceCollectionExtensions.cs` |
+| `AddBarrierAuditSinks` | `src/Nexo.Runtime/RuntimeServiceCollectionExtensions.cs` (`Nexo:Audit:*`) |
+| NCR + Ollama backend | `src/Nexo.Hosting/NexoServiceCollectionExtensions.NodeCapabilityRuntime.cs` |
+
+### Planned feature flag
+
+`Nexo:UseMeaiPipeline` — **does not exist yet**. Follow existing pattern: bind bool from config section + optional env override; default **off** until Phase 6. Suggested env alias: `NEXO_USE_MEAI_PIPELINE=1`.
+
+### AWS credentials (for Phase 4 Bedrock reuse)
+
+| Piece | Path | Notes |
+|-------|------|-------|
+| DynamoDB store | `src/Nexo.Ingress.DynamoDb/` | `new AmazonDynamoDBClient()` — **default credential/region chain** |
+| Options | `src/Nexo.Contracts/SmsIngressDynamoDbOptions.cs` | Table name only (`Nexo:SmsIngressDynamoDb`) |
+| SNS | `src/Nexo.Ingress.AwsSns/` | Signature verify only — **no AWS SDK client** |
+| Packages | `Directory.Packages.props` | `AWSSDK.DynamoDBv2` 3.7.400, Core/S3/Lambda 3.7.305.12 — **no Bedrock** |
+
+---
+
+## Proposed mapping: existing type → MEAI concept
+
+| Existing type | MEAI concept | Notes |
+|---------------|--------------|-------|
+| `IModel` / `ProviderBackedModel` | Consumer of `IChatClient` (adapter) | Keep `IModel` until Phase 6; impl can call MEAI when flag on |
+| `IProviderFactory` / `ProviderFactory` | Provider `IChatClient`s behind keyed DI | Split per target; do not register raw factory/clients publicly |
+| `OllamaProvider` | `IChatClient` via **OllamaSharp** (or thin adapter) | Key: `local:ollama` |
+| `LocalModelProvider` (LLamaSharp) | Custom `IChatClient` adapter | Key: `local:onnx` alias or `local:llamasharp` — see §1 |
+| `SanitizingProviderFactory` + `ICloudSanitizationProxy` | `SanitizingChatClient : DelegatingChatClient` | Move policy-driven redact/block/pass here |
+| `ISensitiveContentFilter` | Used inside `SanitizingChatClient` | Reuse; don’t rewrite filters |
+| `IDataDecisionAuditLog` / sanitization audit | `AuditingChatClient : DelegatingChatClient` | Emit counts/categories only |
+| `ITrustPolicyPackRegistry` + target allow-list (new) | `PolicyGateChatClient : DelegatingChatClient` | New gate over (caller, target, model) |
+| `AdaptiveProviderFactory` / `ILoadPolicy` / `NcrCapabilityRouter` | `RoutingChatClient : IChatClient` | Phase 3; local-first + policy × availability |
+| *(none)* Bedrock | `BedrockChatClient : IChatClient` | Phase 4; keys `cloud:bedrock:{fast,balanced,heavy}` |
+| Nexo `IEmbeddingGenerator` / `TokenEmbeddingGenerator` | MEAI `IEmbeddingGenerator>` | Phase 5; rename Nexo interface or fully qualify |
+| `IVectorStore` / `InMemoryVectorStore` / `SqliteVectorStore` | `VectorStore` / `VectorStoreCollection` (Microsoft.Extensions.VectorData) | Keep old read-only until Phase 6 |
+| `RAGService` / `KnowledgeBaseIndexer` | Facades over VectorData + embedding generator | Preserve sensitivity ≤ caller filter |
+| Raw `OllamaApiClient` / Bedrock / ONNX session | **Never** resolve from DI | Only decorated `IChatClient` pipeline is public |
+
+### Fixed governance composition (Phase 2)
+
+```
+UseNexoGovernance() →
+ PolicyGate → Sanitizing → Auditing → [UseFunctionInvocation()] → provider IChatClient
+```
+
+Router (Phase 3) sits **outside** per-target stacks and is itself wrapped in Auditing.
+
+---
+
+## Gaps & risks for later phases
+
+1. **TFM mismatch:** plan = .NET 9; repo libraries/hosts = `net8.0`. `Microsoft.Extensions.*` already at **10.0.8** in CPM — MEAI packages should align carefully.
+2. **Dual invocation paths:** `IModel`/`IProviderFactory` and NCR `IModelServingBackend` — decide whether NCR remains parallel or folds into MEAI (recommend: Phase 1 wraps chat path only; NCR later).
+3. **Bypass surface:** many direct `IProviderFactory` callers — flag must route them or Phase 6 cleanup will leave holes.
+4. **“ONNX” naming** vs LLamaSharp reality — document in policy keys to avoid operator confusion.
+5. **Sanitization not per-target today** — Proxy is cloud-oriented; local pass-through must become explicit policy.
+6. **Two audit models** — prefer trust data-decision audit for MEAI middleware; barrier sinks for barriers.
+7. **Package asks (plan allows these):**
+ - Phase 1: `Microsoft.Extensions.AI.Abstractions`, `Microsoft.Extensions.AI`, `OllamaSharp`
+ - Phase 4: `AWSSDK.BedrockRuntime` (+ AWS MEAI adapter if available)
+ - Phase 5: `Microsoft.Extensions.VectorData.Abstractions` + one concrete store
+8. **No Bedrock / no Anthropic** yet; Amazon credentials path is default chain only.
+9. **RAG sensitivity ≠ mesh trust tier** — map Phase 5 tags to `IDataSensitivityRegistry`.
+
+---
+
+## Neighbor projects for `src/Nexo.AI.Pipeline`
+
+| Project | Why |
+|---------|-----|
+| `Nexo.Abstractions` | `IModel`, barriers, tools |
+| `Nexo.BackgroundAgents` | Trust sanitization, RAG (reuse; avoid circular refs — prefer ports/interfaces) |
+| `Nexo.Infrastructure` | ProviderFactory, Ollama, LLamaSharp (adapters wrap, don’t rewrite) |
+| `Nexo.Hosting` | Feature-flagged `ChatClientBuilder` registration |
+| `Nexo.Core.Application` | Trust ports, NCR ports |
+| `Nexo.Adapters.Models` | Lightweight model adapters peer |
+| Tests: `Nexo.Tests.BackgroundAgents`, new `Nexo.Tests.AI.Pipeline` | Unit + composition + architecture tests |
+
+**Suggested dependency direction:** `Nexo.AI.Pipeline` depends on Abstractions + Application ports + MEAI packages; Infrastructure/BackgroundAgents provide adapters registered from Hosting. Avoid Pipeline → Hosting.
+
+---
+
+## Phase checklist
+
+| Phase | Deliverable | Status |
+|-------|-------------|--------|
+| 0 | This notes file | **Done** |
+| 1 | `Nexo.AI.Pipeline` + Ollama/LLamaSharp `IChatClient` + flag off | **Done** |
+| 2 | PolicyGate / Sanitizing / Auditing middleware + DI architecture tests | **Done** |
+| 3 | `RoutingChatClient` + policy × availability matrix tests | **Done** |
+| 4 | Bedrock tiered targets + env-gated integration test | **Done** |
+| 5 | VectorData RAG + embedding middleware + reindex CLI | **Done** |
+| 6 | Flag default on; delete legacy; `docs/governed-pipeline.md` | **Done** |
+
+---
+
+## Phase 1 implementation notes (2026-07-14)
+
+Landing branch: `cursor/meai-phase1-pipeline-5a04`
+
+### Delivered
+- New project `src/Nexo.AI.Pipeline` (TFMs `net8.0;net9.0`) + tests `src/Nexo.Tests.AI.Pipeline`
+- Keyed `IChatClient` targets: `local:ollama` (`OllamaHttpChatClient`), `local:onnx` (`LlamaSharpChatClient`)
+- Hosting Phase **13b** registers the pipeline only when `Nexo:UseMeaiPipeline` / `NEXO_USE_MEAI_PIPELINE` / `NexoHostingOptions.UseMeaiPipeline` is true (**default off**)
+- Raw `OllamaHttpChatClient` / `LlamaSharpChatClient` are **not** registered in DI — only keyed `IChatClient` via `AddKeyedChatClient`
+- Packages: `Microsoft.Extensions.AI` + `Abstractions` **10.7.0**; CPM bumped related `Microsoft.Extensions.*` / `System.Text.*` **10.0.8 → 10.0.9** for MEAI
+
+### Discovery changes for later phases
+1. **OllamaSharp deferred:** package 5.4.25 ships a Roslyn 5 analyzer incompatible with this repo's pinned C# 12 / compiler 4.14. Phase 1 uses a thin `OllamaHttpChatClient` over `/api/chat` instead (plan-allowed). Revisit OllamaSharp when the repo moves to a Roslyn 5-capable toolchain.
+2. **`local:onnx` = LLamaSharp GGUF** confirmed in code comments + options; not ONNX Runtime GenAI.
+3. Host libraries remain **net8.0**; Pipeline dual-targets so Hosting can consume net8 while still shipping net9.
+4. Governance middleware (`UseNexoGovernance`) is **Phase 2** — Phase 1 registers bare keyed clients through `ChatClientBuilder` with no policy/sanitize/audit stack yet.
+
+---
+
+## Phase 2 implementation notes (2026-07-14)
+
+Landing branch: `cursor/meai-phase2-governance-5a04`
+
+### Delivered
+- `UseNexoGovernance(targetKey)` — fixed order **PolicyGate → Sanitizing → Auditing → provider**
+- `PolicyViolationException` with structured `Code` / target / details (no raw secrets)
+- Ports: `IChatTargetAccessPolicy`, `IChatMessageSanitizer`, `ITargetSanitizePolicy`, `IChatInvocationAuditor`
+- Defaults: local allow / cloud deny; local sanitize=Pass; cloud sanitize=BlockOnSecretRedactOnPii
+- `AddNexoMeaiPipeline` always applies `UseNexoGovernance` (hosts cannot register ungoverned keyed clients through this API)
+- Unit tests: deny short-circuit, PII redact before spy, audit on success/fault/cancel, composition order, architecture (resolved client is `PolicyGateChatClient`)
+
+### Follow-ups for later phases
+- Wire adapters to existing `ICloudSanitizationProxy` / `IDataDecisionAuditLog` / trust packs (ports are ready)
+- Phase 3 router wraps governed per-target pipelines and audits route decisions
+
+---
+
+## Phase 3 implementation notes (2026-07-14)
+
+Landing branch: `cursor/meai-phase3-router-5a04`
+
+### Delivered
+- `RoutingChatClient` + `LocalFirstChatRouter` + `DefaultRouteCandidateTable` + `ITargetAvailability`
+- Capability hints via `ChatOptions.AdditionalProperties["nexo.route.capability"]` (fast/balanced/heavy)
+- Local-first escalation; cloud candidates only when policy allow-lists cloud keys
+- Router emits audit records (`router:default`) with candidates + reason; default DI wraps router in `AuditingChatClient`
+- Table-driven policy × availability matrix tests + scenario tests (cloud forbidden / local down fallback / hard fail)
+
+### Follow-ups
+- Phase 4 registers real `cloud:bedrock:*` governed clients; stubs already reserved in the candidate table
+
+---
+
+## Phase 4 implementation notes (2026-07-14)
+
+Landing branch: `cursor/meai-phase4-bedrock-5a04`
+
+### Delivered
+- Packages: `AWSSDK.BedrockRuntime` + `AWSSDK.Extensions.Bedrock.MEAI` (AWS MEAI `AsIChatClient`)
+- Targets `cloud:bedrock:fast|balanced|heavy` with config model ids (`Nexo:Meai:Bedrock`)
+- Credentials: `new AmazonBedrockRuntimeClient()` — same default chain as DynamoDB ingress; client **not** in DI
+- Cloud sanitize defaults already strict (`BlockOnSecretRedactOnPii`); Bedrock enable auto-allow-lists cloud target keys
+- Unit tests with fake transport; live test gated by `NEXO_TEST_BEDROCK=1`
+
+---
+
+## Phase 5 implementation notes (2026-07-14)
+
+Landing branch: `cursor/meai-phase5-vectordata-5a04`
+
+### Delivered
+- Package: `Microsoft.Extensions.VectorData.Abstractions` **10.7.0** + in-process `InProcessVectorStore` / `InProcessChunkCollection` (no preview SK connector — version mismatch with VectorData 10.7)
+- `ChunkRecord`, `TrustTierOrder`, `VectorDataRagService` (index / search with caller-tier filter / reindex)
+- Governed embeddings: `TokenHashEmbeddingGenerator` → **Auditing** → **Sanitizing** (outer) — same AsyncLocal visibility pattern as chat
+- Hosting Phase 13b: VectorData RAG + governance defaults always; chat pipeline still flag-gated
+- CLI: `nexo background-agent rag reindex-meai` (`MeaiRagReindexCommand`) — leaves legacy RAG store read-only
+- AWS CPM aligned to v4 so Bedrock MEAI + DynamoDB co-restore: Core `4.0.100.4`, DynamoDBv2 `4.0.101.1`, S3 `4.0.101`, Lambda `4.0.103`
+
+### Follow-ups for Phase 6
+- Default `Nexo:UseMeaiPipeline` on; remove legacy `IProviderFactory` chat path and legacy RAG write path
+- Publish `docs/governed-pipeline.md` + architecture tests in CI
+- Optional: swap in-process VectorData store for a durable connector when one matches Abstractions 10.7
+- **CLI reindex:** deferred to an `application/*` PR (layer-boundary: master cannot change `application/`) — use `VectorDataRagService.ReindexAsync` via Hosting DI until then
+
+---
+
+## Phase 6 implementation notes (2026-07-14)
+
+Landing branch: `cursor/meai-phase6-cutover-5a04`
+
+### Delivered
+- Feature flag **defaults ON**; opt out with `Nexo:UseMeaiPipeline=false` / `NEXO_USE_MEAI_PIPELINE=0`
+- `MeaiBackedModel` (`IModel` → governed `IChatClient`); Hosting Phase 13 uses it as the HotSwappable agentic leaf when MEAI is on
+- `HotSwappableModel` accepts `IModel` (not `ProviderBackedModel` only)
+- Default `IRAGService` → `MeaiVectorDataRagAdapter` over `VectorDataRagService`
+- Operator doc: `docs/governed-pipeline.md`
+- CI: `make meai-pipeline-gate` hooked into `make kernel-gate`
+- Pack graph: `Nexo.AI.Pipeline` added to `pack-nexo-hosting-graph.{sh,ps1}`
+- ProdStyle Hosting smoke: default MEAI + VectorData RAG wiring / opt-out
+
+### Soft-gated (kept)
+- `IProviderFactory` / `ProviderFactory` / `SanitizingProviderFactory` for direct non-chat callers (NCR, bricks, content generators)
+- Legacy `RAGService` types remain for opt-out/custom hosts
+
+### Remaining debt
+- Migrate remaining direct `IProviderFactory.ExecuteLLMAsync` callers onto MEAI or shared policy helpers
+- Durable VectorData connector when GA versions align
+- Application-layer CLI: `nexo background-agent rag reindex-meai` (blocked on master by layer-boundary; land on `application/*`)
diff --git a/scripts/pack-nexo-hosting-graph.ps1 b/scripts/pack-nexo-hosting-graph.ps1
index e4f13da5..8700f789 100644
--- a/scripts/pack-nexo-hosting-graph.ps1
+++ b/scripts/pack-nexo-hosting-graph.ps1
@@ -38,6 +38,7 @@ Pack-Project "src/Nexo.Certification.Physical/Nexo.Certification.Physical.csproj
Pack-Project "src/Nexo.Infrastructure/Nexo.Infrastructure.csproj"
Pack-Project "src/Nexo.Orchestration/Nexo.Orchestration.csproj"
Pack-Project "src/Nexo.BackgroundAgents/Nexo.BackgroundAgents.csproj"
+Pack-Project "src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj"
Pack-Project "src/Nexo.Hosting/Nexo.Hosting.csproj"
$cfg = Join-Path $OutputDir "PackBundle.NuGet.Config"
diff --git a/scripts/pack-nexo-hosting-graph.sh b/scripts/pack-nexo-hosting-graph.sh
index 77786f29..31a73842 100755
--- a/scripts/pack-nexo-hosting-graph.sh
+++ b/scripts/pack-nexo-hosting-graph.sh
@@ -34,6 +34,7 @@ pack src/Nexo.Certification.Physical/Nexo.Certification.Physical.csproj
pack src/Nexo.Infrastructure/Nexo.Infrastructure.csproj
pack src/Nexo.Orchestration/Nexo.Orchestration.csproj
pack src/Nexo.BackgroundAgents/Nexo.BackgroundAgents.csproj
+pack src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj
pack src/Nexo.Hosting/Nexo.Hosting.csproj
CFG="${OUT}/PackBundle.NuGet.Config"
diff --git a/src/Nexo.AI.Pipeline/BedrockMeaiOptions.cs b/src/Nexo.AI.Pipeline/BedrockMeaiOptions.cs
new file mode 100644
index 00000000..51298f72
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/BedrockMeaiOptions.cs
@@ -0,0 +1,23 @@
+namespace Nexo.AI.Pipeline;
+
+///
+/// Configurable AWS Bedrock model ids for tiered cloud targets.
+/// Model families change; keep ids in config, not code.
+///
+public sealed class BedrockMeaiOptions
+{
+ /// When true, register cloud:bedrock:* governed clients.
+ public bool Enabled { get; set; }
+
+ /// Optional AWS region (falls back to default credential/region chain).
+ public string? Region { get; set; }
+
+ /// Model id for cloud:bedrock:fast.
+ public string FastModelId { get; set; } = "amazon.nova-micro-v1:0";
+
+ /// Model id for cloud:bedrock:balanced.
+ public string BalancedModelId { get; set; } = "amazon.nova-lite-v1:0";
+
+ /// Model id for cloud:bedrock:heavy.
+ public string HeavyModelId { get; set; } = "amazon.nova-pro-v1:0";
+}
diff --git a/src/Nexo.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs b/src/Nexo.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs
new file mode 100644
index 00000000..4b255b82
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Clients/AwsBedrockChatClientFactory.cs
@@ -0,0 +1,44 @@
+using Amazon;
+using Amazon.BedrockRuntime;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Options;
+
+namespace Nexo.AI.Pipeline.Clients;
+
+///
+/// Creates Bedrock-backed instances without registering
+/// / in DI.
+/// Uses the same default AWS credential/region chain as DynamoDB SMS ingress.
+///
+public interface IBedrockChatClientFactory
+{
+ /// Creates an for the given Bedrock model id.
+ IChatClient Create(string modelId);
+}
+
+///
+/// Production factory over AWSSDK Bedrock MEAI adapter.
+///
+public sealed class AwsBedrockChatClientFactory : IBedrockChatClientFactory
+{
+ private readonly BedrockMeaiOptions _options;
+
+ /// Creates the factory from pipeline options.
+ public AwsBedrockChatClientFactory(IOptions options)
+ {
+ _options = options.Value.Bedrock;
+ }
+
+ ///
+ public IChatClient Create(string modelId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(modelId);
+
+ // Construct privately — never AddSingleton.
+ IAmazonBedrockRuntime runtime = string.IsNullOrWhiteSpace(_options.Region)
+ ? new AmazonBedrockRuntimeClient()
+ : new AmazonBedrockRuntimeClient(RegionEndpoint.GetBySystemName(_options.Region.Trim()));
+
+ return runtime.AsIChatClient(modelId);
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs b/src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs
new file mode 100644
index 00000000..ef16030b
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Clients/FakeChatClient.cs
@@ -0,0 +1,63 @@
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Clients;
+
+///
+/// Test / offline double that returns a fixed assistant reply.
+///
+public sealed class FakeChatClient : IChatClient
+{
+ private readonly string _response;
+ private readonly string _modelId;
+
+ /// Creates a fake client that always returns .
+ public FakeChatClient(string response = "fake-response", string modelId = "fake")
+ {
+ _response = response;
+ _modelId = modelId;
+ }
+
+ ///
+ public Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, _response))
+ {
+ ModelId = options?.ModelId ?? _modelId,
+ });
+ }
+
+ ///
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+ yield return new ChatResponseUpdate(ChatRole.Assistant, _response)
+ {
+ ModelId = options?.ModelId ?? _modelId,
+ };
+ }
+
+ ///
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ if (serviceType == typeof(ChatClientMetadata))
+ {
+ return new ChatClientMetadata("fake", providerUri: null, defaultModelId: _modelId);
+ }
+
+ return serviceType.IsInstanceOfType(this) ? this : null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs b/src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs
new file mode 100644
index 00000000..76324e65
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Clients/LlamaSharpChatClient.cs
@@ -0,0 +1,168 @@
+using System.Runtime.CompilerServices;
+using System.Text;
+using LLama;
+using LLama.Common;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Options;
+
+namespace Nexo.AI.Pipeline.Clients;
+
+///
+/// adapter over in-process LLamaSharp GGUF inference.
+/// Registered under keyed DI name local:onnx (see migration notes: not ONNX Runtime).
+///
+public sealed class LlamaSharpChatClient : IChatClient
+{
+ private static readonly object Gate = new();
+ private static LLamaWeights? s_weights;
+ private static LLamaContext? s_context;
+ private static string? s_loadedPath;
+
+ private readonly MeaiPipelineOptions _options;
+
+ /// Creates a LLamaSharp-backed chat client.
+ public LlamaSharpChatClient(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ ///
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var text = await CompleteAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ return new ChatResponse(new ChatMessage(ChatRole.Assistant, text))
+ {
+ ModelId = options?.ModelId ?? "llamasharp-gguf",
+ };
+ }
+
+ ///
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // LocalModelProvider-style inference is session-based; surface as a single streamed chunk.
+ var text = await CompleteAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ yield return new ChatResponseUpdate(ChatRole.Assistant, text)
+ {
+ ModelId = options?.ModelId ?? "llamasharp-gguf",
+ };
+ }
+
+ ///
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ if (serviceType == typeof(ChatClientMetadata))
+ {
+ return new ChatClientMetadata("llamasharp", providerUri: null, defaultModelId: "llamasharp-gguf");
+ }
+
+ return serviceType.IsInstanceOfType(this) ? this : null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ // Weights/context are process-scoped (shared with Hosting LocalModelProvider pattern).
+ }
+
+ private async Task CompleteAsync(
+ IEnumerable messages,
+ ChatOptions? options,
+ CancellationToken cancellationToken)
+ {
+ var path = ResolveModelPath();
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ throw new InvalidOperationException(
+ "Local model not configured. Set Nexo:Meai:LocalModelPath or NEXO_LOCAL_MODEL_PATH to a GGUF file.");
+ }
+
+ EnsureLoaded(path, _options.LocalContextSize);
+
+ if (s_weights is null || s_context is null)
+ {
+ throw new InvalidOperationException("Failed to load local GGUF model.");
+ }
+
+ var prompt = BuildPrompt(messages);
+ var executor = new InteractiveExecutor(s_context);
+ var session = new ChatSession(executor);
+ var maxTokens = options?.MaxOutputTokens ?? _options.LocalMaxTokens;
+ var inferenceParams = new InferenceParams
+ {
+ MaxTokens = maxTokens,
+ AntiPrompts = new List { "User:", "user:" },
+ };
+
+ var sb = new StringBuilder();
+ await foreach (var token in session.ChatAsync(
+ new ChatHistory.Message(AuthorRole.User, prompt),
+ inferenceParams,
+ cancellationToken).ConfigureAwait(false))
+ {
+ sb.Append(token);
+ }
+
+ return sb.ToString();
+ }
+
+ private string? ResolveModelPath()
+ {
+ var path = _options.LocalModelPath;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ path = Environment.GetEnvironmentVariable("NEXO_LOCAL_MODEL_PATH");
+ }
+
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ path = Environment.ExpandEnvironmentVariables(path.Trim());
+ return Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
+ }
+
+ private static string BuildPrompt(IEnumerable messages)
+ {
+ var sb = new StringBuilder();
+ foreach (var message in messages)
+ {
+ var role = message.Role == ChatRole.System ? "System"
+ : message.Role == ChatRole.Assistant ? "Assistant"
+ : "User";
+ sb.Append(role).Append(": ").Append(message.Text).Append('\n');
+ }
+
+ return sb.ToString();
+ }
+
+ private static void EnsureLoaded(string path, int contextSize)
+ {
+ if (s_weights is not null && string.Equals(s_loadedPath, path, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ lock (Gate)
+ {
+ if (s_weights is not null && string.Equals(s_loadedPath, path, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var parameters = new ModelParams(path)
+ {
+ ContextSize = (uint)Math.Max(256, contextSize),
+ };
+ s_weights = LLamaWeights.LoadFromFile(parameters);
+ s_context = s_weights.CreateContext(parameters);
+ s_loadedPath = path;
+ }
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs b/src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs
new file mode 100644
index 00000000..5d59533c
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Clients/OllamaHttpChatClient.cs
@@ -0,0 +1,202 @@
+using System.Net.Http.Json;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Options;
+
+namespace Nexo.AI.Pipeline.Clients;
+
+///
+/// Thin over Ollama's native /api/chat HTTP API.
+/// Avoids registering any Ollama SDK client in DI.
+///
+public sealed class OllamaHttpChatClient : IChatClient, IDisposable
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ private readonly HttpClient _http;
+ private readonly string _defaultModel;
+ private readonly bool _ownsHttp;
+
+ /// Creates an Ollama HTTP chat client from pipeline options.
+ public OllamaHttpChatClient(IOptions options)
+ : this(CreateHttpClient(options.Value), ResolveModel(options.Value), ownsHttp: true)
+ {
+ }
+
+ /// Creates an Ollama HTTP chat client with an injected (tests).
+ public OllamaHttpChatClient(HttpClient http, string defaultModel, bool ownsHttp = false)
+ {
+ _http = http ?? throw new ArgumentNullException(nameof(http));
+ _defaultModel = string.IsNullOrWhiteSpace(defaultModel) ? "llama3.1:latest" : defaultModel;
+ _ownsHttp = ownsHttp;
+ }
+
+ ///
+ public async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var payload = BuildRequest(messages, options, stream: false);
+ using var response = await _http.PostAsJsonAsync("api/chat", payload, JsonOptions, cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var body = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken)
+ .ConfigureAwait(false);
+ var text = body?.Message?.Content ?? string.Empty;
+ return new ChatResponse(new ChatMessage(ChatRole.Assistant, text))
+ {
+ ModelId = body?.Model ?? options?.ModelId ?? _defaultModel,
+ };
+ }
+
+ ///
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var payload = BuildRequest(messages, options, stream: true);
+ using var request = new HttpRequestMessage(HttpMethod.Post, "api/chat")
+ {
+ Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"),
+ };
+ using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+ while (!reader.EndOfStream)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ var chunk = JsonSerializer.Deserialize(line, JsonOptions);
+ var content = chunk?.Message?.Content;
+ if (!string.IsNullOrEmpty(content))
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, content)
+ {
+ ModelId = chunk?.Model ?? options?.ModelId ?? _defaultModel,
+ };
+ }
+
+ if (chunk?.Done == true)
+ {
+ yield break;
+ }
+ }
+ }
+
+ ///
+ public object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ if (serviceType == typeof(ChatClientMetadata))
+ {
+ return new ChatClientMetadata("ollama", _http.BaseAddress, _defaultModel);
+ }
+
+ return serviceType.IsInstanceOfType(this) ? this : null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_ownsHttp)
+ {
+ _http.Dispose();
+ }
+ }
+
+ private object BuildRequest(IEnumerable messages, ChatOptions? options, bool stream)
+ {
+ var model = string.IsNullOrWhiteSpace(options?.ModelId) ? _defaultModel : options!.ModelId!;
+ var ollamaMessages = messages.Select(m => new
+ {
+ role = MapRole(m.Role),
+ content = m.Text ?? string.Empty,
+ }).ToList();
+
+ return new
+ {
+ model,
+ messages = ollamaMessages,
+ stream,
+ options = options?.Temperature is null && options?.MaxOutputTokens is null
+ ? null
+ : new
+ {
+ temperature = options?.Temperature,
+ num_predict = options?.MaxOutputTokens,
+ },
+ };
+ }
+
+ private static string MapRole(ChatRole role)
+ {
+ if (role == ChatRole.System) return "system";
+ if (role == ChatRole.Assistant) return "assistant";
+ if (role == ChatRole.Tool) return "tool";
+ return "user";
+ }
+
+ private static HttpClient CreateHttpClient(MeaiPipelineOptions options)
+ {
+ var baseUrl = ResolveBaseUrl(options);
+ return new HttpClient
+ {
+ BaseAddress = new Uri(baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/"),
+ Timeout = TimeSpan.FromSeconds(300),
+ };
+ }
+
+ private static string ResolveBaseUrl(MeaiPipelineOptions options)
+ {
+ var env = Environment.GetEnvironmentVariable("NEXO_OLLAMA_BASE_URL");
+ if (!string.IsNullOrWhiteSpace(env))
+ {
+ return env.Trim().TrimEnd('/');
+ }
+
+ return string.IsNullOrWhiteSpace(options.OllamaBaseUrl)
+ ? "http://localhost:11434"
+ : options.OllamaBaseUrl.Trim().TrimEnd('/');
+ }
+
+ private static string ResolveModel(MeaiPipelineOptions options)
+ {
+ var env = Environment.GetEnvironmentVariable("NEXO_OLLAMA_MODEL");
+ if (!string.IsNullOrWhiteSpace(env))
+ {
+ return env.Trim();
+ }
+
+ return string.IsNullOrWhiteSpace(options.OllamaModel) ? "llama3.1:latest" : options.OllamaModel.Trim();
+ }
+
+ private sealed class OllamaChatResponse
+ {
+ public string? Model { get; set; }
+ public OllamaMessage? Message { get; set; }
+ public bool Done { get; set; }
+ }
+
+ private sealed class OllamaMessage
+ {
+ public string? Role { get; set; }
+ public string? Content { get; set; }
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs b/src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs
new file mode 100644
index 00000000..effbb5b6
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Clients/SpyChatClient.cs
@@ -0,0 +1,64 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Clients;
+
+///
+/// Test spy that records invocations and returns a fixed response.
+///
+public sealed class SpyChatClient : IChatClient
+{
+ private readonly string _response;
+ private readonly ConcurrentQueue> _calls = new();
+
+ /// Creates a spy client.
+ public SpyChatClient(string response = "spy-ok")
+ {
+ _response = response;
+ }
+
+ /// Captured outbound message lists (one per call).
+ public IReadOnlyList> Calls => _calls.ToArray();
+
+ /// Number of times the spy was invoked.
+ public int CallCount => _calls.Count;
+
+ ///
+ public Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _calls.Enqueue(messages.ToList());
+ return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, _response))
+ {
+ ModelId = options?.ModelId ?? "spy",
+ });
+ }
+
+ ///
+ public async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _calls.Enqueue(messages.ToList());
+ await Task.Yield();
+ yield return new ChatResponseUpdate(ChatRole.Assistant, _response)
+ {
+ ModelId = options?.ModelId ?? "spy",
+ };
+ }
+
+ ///
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType.IsInstanceOfType(this) ? this : null;
+
+ ///
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Embeddings/GovernedEmbeddingGenerator.cs b/src/Nexo.AI.Pipeline/Embeddings/GovernedEmbeddingGenerator.cs
new file mode 100644
index 00000000..2a837bfe
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Embeddings/GovernedEmbeddingGenerator.cs
@@ -0,0 +1,109 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+using Nexo.AI.Pipeline.Governance;
+
+namespace Nexo.AI.Pipeline.Embeddings;
+
+///
+/// Sanitizes outbound embedding inputs (queries and documents) before generation.
+///
+public sealed class SanitizingEmbeddingGenerator : DelegatingEmbeddingGenerator>
+{
+ private readonly IChatMessageSanitizer _sanitizer;
+ private readonly SanitizeDisposition _disposition;
+
+ /// Creates a sanitizing embedding decorator.
+ public SanitizingEmbeddingGenerator(
+ IEmbeddingGenerator> inner,
+ IChatMessageSanitizer sanitizer,
+ SanitizeDisposition disposition = SanitizeDisposition.Redact)
+ : base(inner)
+ {
+ _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer));
+ _disposition = disposition;
+ }
+
+ ///
+ public override async Task>> GenerateAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var sanitized = new List();
+ foreach (var value in values)
+ {
+ var messages = new List { new(ChatRole.User, value ?? string.Empty) };
+ var result = _sanitizer.Sanitize(messages, targetKey: "embed:local", _disposition, cancellationToken);
+ if (!result.Allowed)
+ {
+ throw new PolicyViolationException(
+ code: "sanitization_blocked",
+ message: result.BlockReason ?? "Embedding input blocked by sanitization policy.",
+ targetKey: "embed:local");
+ }
+
+ sanitized.Add(result.Messages![0].Text ?? string.Empty);
+ // Set before awaiting the audit/provider layer so the inner auditor can read it.
+ SanitizationCallContext.Result = result;
+ }
+
+ return await base.GenerateAsync(sanitized, options, cancellationToken).ConfigureAwait(false);
+ }
+}
+
+///
+/// Audits embedding generation calls (counts only; never logs content).
+///
+public sealed class AuditingEmbeddingGenerator : DelegatingEmbeddingGenerator>
+{
+ private readonly IChatInvocationAuditor _auditor;
+
+ /// Creates an auditing embedding decorator.
+ public AuditingEmbeddingGenerator(
+ IEmbeddingGenerator> inner,
+ IChatInvocationAuditor auditor)
+ : base(inner)
+ {
+ _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor));
+ }
+
+ ///
+ public override async Task>> GenerateAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var materialised = values.ToList();
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ var result = await base.GenerateAsync(materialised, options, cancellationToken).ConfigureAwait(false);
+ sw.Stop();
+ var sanitize = SanitizationCallContext.Result;
+ _auditor.Record(new ChatInvocationAuditRecord
+ {
+ TargetKey = "embed:local",
+ Outcome = "success",
+ PolicyDecisions = new[] { "event=embedding", $"inputs={materialised.Count}" },
+ RedactionCount = sanitize?.RedactionCount ?? 0,
+ RedactionCategories = sanitize?.Categories ?? Array.Empty(),
+ LatencyMs = sw.ElapsedMilliseconds,
+ });
+ return result;
+ }
+ catch (Exception)
+ {
+ sw.Stop();
+ _auditor.Record(new ChatInvocationAuditRecord
+ {
+ TargetKey = "embed:local",
+ Outcome = "fault",
+ PolicyDecisions = new[] { "event=embedding" },
+ ReasonCode = "fault",
+ LatencyMs = sw.ElapsedMilliseconds,
+ });
+ throw;
+ }
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Embeddings/TokenHashEmbeddingGenerator.cs b/src/Nexo.AI.Pipeline/Embeddings/TokenHashEmbeddingGenerator.cs
new file mode 100644
index 00000000..0bd7bb0e
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Embeddings/TokenHashEmbeddingGenerator.cs
@@ -0,0 +1,86 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Embeddings;
+
+///
+/// Deterministic local embedding generator (token-hash bag-of-words), MEAI-shaped.
+/// Does not collide with Nexo.BackgroundAgents.RAG.IEmbeddingGenerator.
+///
+public sealed class TokenHashEmbeddingGenerator : IEmbeddingGenerator>
+{
+ private readonly int _dimensions;
+
+ /// Creates a generator with the given dimensionality (default 64).
+ public TokenHashEmbeddingGenerator(int dimensions = ChunkRecordDimensions.Default)
+ {
+ if (dimensions <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(dimensions));
+ }
+
+ _dimensions = dimensions;
+ }
+
+ ///
+ public Task>> GenerateAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var list = new List>();
+ foreach (var value in values)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ list.Add(new Embedding(Embed(value ?? string.Empty)));
+ }
+
+ return Task.FromResult(new GeneratedEmbeddings>(list));
+ }
+
+ ///
+ public object? GetService(Type serviceType, object? serviceKey = null) =>
+ serviceType.IsInstanceOfType(this) ? this : null;
+
+ ///
+ public void Dispose()
+ {
+ }
+
+ private float[] Embed(string text)
+ {
+ var vector = new float[_dimensions];
+ var tokens = text.Split([' ', '\t', '\r', '\n', ',', '.', ';', ':', '!', '?'],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ foreach (var token in tokens)
+ {
+ var hash = token.ToLowerInvariant().GetHashCode();
+ var index = Math.Abs(hash) % _dimensions;
+ vector[index] += 1f;
+ }
+
+ // L2 normalize
+ double sumSq = 0;
+ for (var i = 0; i < vector.Length; i++)
+ {
+ sumSq += vector[i] * vector[i];
+ }
+
+ if (sumSq > double.Epsilon)
+ {
+ var norm = (float)Math.Sqrt(sumSq);
+ for (var i = 0; i < vector.Length; i++)
+ {
+ vector[i] /= norm;
+ }
+ }
+
+ return vector;
+ }
+}
+
+/// Shared embedding dimension constant to avoid circular refs with Rag namespace.
+internal static class ChunkRecordDimensions
+{
+ public const int Default = 64;
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs b/src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs
new file mode 100644
index 00000000..96789776
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/AuditingChatClient.cs
@@ -0,0 +1,173 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Emits an audit record for every invocation. Streaming aggregates into one record at completion
+/// (or a distinct record on cancellation/fault).
+///
+public sealed class AuditingChatClient : DelegatingChatClient
+{
+ private readonly IChatInvocationAuditor _auditor;
+ private readonly string _targetKey;
+ private readonly Func? _correlationIdAccessor;
+
+ /// Creates an auditing decorator.
+ public AuditingChatClient(
+ IChatClient innerClient,
+ IChatInvocationAuditor auditor,
+ string targetKey,
+ Func? correlationIdAccessor = null)
+ : base(innerClient)
+ {
+ _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor));
+ _targetKey = targetKey ?? throw new ArgumentNullException(nameof(targetKey));
+ _correlationIdAccessor = correlationIdAccessor;
+ }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ sw.Stop();
+ Emit("success", response.ModelId ?? options?.ModelId, sw.ElapsedMilliseconds, response.Usage);
+ return response;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ sw.Stop();
+ Emit("cancelled", options?.ModelId, sw.ElapsedMilliseconds, reasonCode: "cancelled");
+ throw;
+ }
+ catch (PolicyViolationException ex)
+ {
+ sw.Stop();
+ Emit("denied", options?.ModelId ?? ex.ModelId, sw.ElapsedMilliseconds, reasonCode: ex.Code);
+ throw;
+ }
+ catch (Exception)
+ {
+ sw.Stop();
+ Emit("fault", options?.ModelId, sw.ElapsedMilliseconds, reasonCode: "fault");
+ throw;
+ }
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var sw = Stopwatch.StartNew();
+ string? modelId = options?.ModelId;
+ UsageDetails? usage = null;
+ var completed = false;
+
+ IAsyncEnumerator? enumerator = null;
+ try
+ {
+ enumerator = base.GetStreamingResponseAsync(messages, options, cancellationToken)
+ .GetAsyncEnumerator(cancellationToken);
+
+ while (true)
+ {
+ bool moved;
+ try
+ {
+ moved = await enumerator.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ sw.Stop();
+ Emit("cancelled", modelId, sw.ElapsedMilliseconds, usage, "cancelled");
+ throw;
+ }
+ catch (PolicyViolationException ex)
+ {
+ sw.Stop();
+ Emit("denied", modelId ?? ex.ModelId, sw.ElapsedMilliseconds, usage, ex.Code);
+ throw;
+ }
+ catch (Exception)
+ {
+ sw.Stop();
+ Emit("fault", modelId, sw.ElapsedMilliseconds, usage, "fault");
+ throw;
+ }
+
+ if (!moved)
+ {
+ break;
+ }
+
+ var update = enumerator.Current;
+ if (!string.IsNullOrWhiteSpace(update.ModelId))
+ {
+ modelId = update.ModelId;
+ }
+
+ foreach (var content in update.Contents)
+ {
+ if (content is UsageContent usageContent)
+ {
+ usage = usageContent.Details;
+ }
+ }
+
+ yield return update;
+ }
+
+ completed = true;
+ sw.Stop();
+ Emit("success", modelId, sw.ElapsedMilliseconds, usage);
+ }
+ finally
+ {
+ if (enumerator is not null)
+ {
+ await enumerator.DisposeAsync().ConfigureAwait(false);
+ }
+
+ _ = completed;
+ }
+ }
+
+ private void Emit(
+ string outcome,
+ string? modelId,
+ long latencyMs,
+ UsageDetails? usage = null,
+ string? reasonCode = null)
+ {
+ var sanitize = SanitizationCallContext.Result;
+ var decisions = new List { $"target={_targetKey}", $"outcome={outcome}" };
+ if (sanitize is not null)
+ {
+ decisions.Add($"sanitize_redactions={sanitize.RedactionCount}");
+ }
+
+ _auditor.Record(new ChatInvocationAuditRecord
+ {
+ TargetKey = _targetKey,
+ ModelId = modelId,
+ Outcome = outcome,
+ PolicyDecisions = decisions,
+ RedactionCount = sanitize?.RedactionCount ?? 0,
+ RedactionCategories = sanitize?.Categories ?? Array.Empty(),
+ InputTokenCount = usage?.InputTokenCount,
+ OutputTokenCount = usage?.OutputTokenCount,
+ LatencyMs = latencyMs,
+ CorrelationId = _correlationIdAccessor?.Invoke(),
+ ReasonCode = reasonCode,
+ });
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs b/src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs
new file mode 100644
index 00000000..b889f4dd
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/DefaultChatMessageSanitizer.cs
@@ -0,0 +1,129 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Built-in PII/secret filter used when no host-specific sanitizer is registered.
+/// Mirrors the categories covered by Nexo's SensitiveContentFilter.
+///
+public sealed partial class DefaultChatMessageSanitizer : IChatMessageSanitizer
+{
+ ///
+ public MessageSanitizationResult Sanitize(
+ IList messages,
+ string targetKey,
+ SanitizeDisposition disposition,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _ = targetKey;
+
+ if (disposition == SanitizeDisposition.Pass)
+ {
+ return MessageSanitizationResult.Allow(messages);
+ }
+
+ var categories = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var redactionCount = 0;
+ var sanitized = new List(messages.Count);
+
+ foreach (var message in messages)
+ {
+ var original = message.Text ?? string.Empty;
+ Detect(original, categories, out var hasSecret, out var hasPii);
+
+ if (disposition == SanitizeDisposition.BlockOnPiiOrSecret && (hasSecret || hasPii))
+ {
+ return MessageSanitizationResult.Block(
+ "Outbound content contains PII or secrets; blocked per policy.",
+ categories.ToList());
+ }
+
+ if (disposition == SanitizeDisposition.BlockOnSecretRedactOnPii && hasSecret)
+ {
+ return MessageSanitizationResult.Block(
+ "Outbound content contains secrets; blocked per policy.",
+ categories.ToList());
+ }
+
+ if (disposition is SanitizeDisposition.Redact
+ or SanitizeDisposition.BlockOnSecretRedactOnPii
+ or SanitizeDisposition.BlockOnPiiOrSecret)
+ {
+ var filtered = Redact(original);
+ if (!string.Equals(filtered, original, StringComparison.Ordinal))
+ {
+ redactionCount++;
+ sanitized.Add(new ChatMessage(message.Role, filtered));
+ continue;
+ }
+ }
+
+ sanitized.Add(message);
+ }
+
+ return MessageSanitizationResult.Allow(sanitized, redactionCount, categories.ToList());
+ }
+
+ private static void Detect(string text, ISet categories, out bool hasSecret, out bool hasPii)
+ {
+ hasSecret = false;
+ hasPii = false;
+
+ if (ApiKeyRegex().IsMatch(text))
+ {
+ hasSecret = true;
+ categories.Add("api-key");
+ }
+
+ if (EmailRegex().IsMatch(text))
+ {
+ hasPii = true;
+ categories.Add("email");
+ }
+
+ if (PhoneRegex().IsMatch(text))
+ {
+ hasPii = true;
+ categories.Add("phone");
+ }
+
+ if (SsnRegex().IsMatch(text))
+ {
+ hasPii = true;
+ categories.Add("ssn");
+ }
+
+ if (CreditCardRegex().IsMatch(text))
+ {
+ hasPii = true;
+ categories.Add("credit-card");
+ }
+ }
+
+ private static string Redact(string text)
+ {
+ text = ApiKeyRegex().Replace(text, "[REDACTED_API_KEY]");
+ text = EmailRegex().Replace(text, "[REDACTED_EMAIL]");
+ text = PhoneRegex().Replace(text, "[REDACTED_PHONE]");
+ text = SsnRegex().Replace(text, "[REDACTED_SSN]");
+ text = CreditCardRegex().Replace(text, "[REDACTED_CC]");
+ return text;
+ }
+
+ [GeneratedRegex(@"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex EmailRegex();
+
+ [GeneratedRegex(@"\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}\b", RegexOptions.CultureInvariant)]
+ private static partial Regex PhoneRegex();
+
+ [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", RegexOptions.CultureInvariant)]
+ private static partial Regex SsnRegex();
+
+ [GeneratedRegex(@"\b(?:sk-|AKIA|ghp_|xox[baprs]-)[A-Za-z0-9\-_]{8,}\b", RegexOptions.CultureInvariant)]
+ private static partial Regex ApiKeyRegex();
+
+ [GeneratedRegex(@"\b(?:\d[ -]*?){13,19}\b", RegexOptions.CultureInvariant)]
+ private static partial Regex CreditCardRegex();
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs b/src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs
new file mode 100644
index 00000000..7181dd65
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/DefaultChatTargetAccessPolicy.cs
@@ -0,0 +1,47 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Default local-first access policy: allows all local:* targets; denies cloud:*
+/// unless explicitly listed in .
+///
+public sealed class DefaultChatTargetAccessPolicy : IChatTargetAccessPolicy
+{
+ /// Optional allow-list of cloud target keys.
+ public ISet AllowedCloudTargets { get; } =
+ new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ public bool IsAllowed(string? callerIdentity, string targetKey, string? modelId, out string? denyReason)
+ {
+ _ = callerIdentity;
+ _ = modelId;
+
+ if (string.IsNullOrWhiteSpace(targetKey))
+ {
+ denyReason = "Target key is required.";
+ return false;
+ }
+
+ if (targetKey.StartsWith("local:", StringComparison.OrdinalIgnoreCase)
+ || targetKey.StartsWith("peer:", StringComparison.OrdinalIgnoreCase))
+ {
+ denyReason = null;
+ return true;
+ }
+
+ if (targetKey.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase))
+ {
+ if (AllowedCloudTargets.Contains(targetKey))
+ {
+ denyReason = null;
+ return true;
+ }
+
+ denyReason = $"Cloud target '{targetKey}' is not permitted by the active policy pack.";
+ return false;
+ }
+
+ denyReason = $"Unknown target trust tier for '{targetKey}'.";
+ return false;
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs b/src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs
new file mode 100644
index 00000000..b0ae44c8
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/IChatInvocationAuditor.cs
@@ -0,0 +1,52 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Audit record for a single model invocation (no raw prompt/response content).
+///
+public sealed class ChatInvocationAuditRecord
+{
+ /// UTC timestamp.
+ public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
+
+ /// Target key (e.g. local:ollama).
+ public required string TargetKey { get; init; }
+
+ /// Model id when known.
+ public string? ModelId { get; init; }
+
+ /// Outcome: success, fault, cancelled, denied.
+ public required string Outcome { get; init; }
+
+ /// Policy decisions applied (e.g. allowed, sanitize=redact).
+ public IReadOnlyList PolicyDecisions { get; init; } = Array.Empty();
+
+ /// Redaction count (content never included).
+ public int RedactionCount { get; init; }
+
+ /// Redaction categories.
+ public IReadOnlyList RedactionCategories { get; init; } = Array.Empty();
+
+ /// Input token count when available.
+ public long? InputTokenCount { get; init; }
+
+ /// Output token count when available.
+ public long? OutputTokenCount { get; init; }
+
+ /// Latency in milliseconds.
+ public long LatencyMs { get; init; }
+
+ /// Optional correlation / barrier identity.
+ public string? CorrelationId { get; init; }
+
+ /// Fault or deny reason code (no secrets).
+ public string? ReasonCode { get; init; }
+}
+
+///
+/// Receives model-invocation audit records.
+///
+public interface IChatInvocationAuditor
+{
+ /// Persists or fans out an audit record.
+ void Record(ChatInvocationAuditRecord record);
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs b/src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs
new file mode 100644
index 00000000..e62cfdd0
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/IChatMessageSanitizer.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Result of sanitizing outbound chat messages.
+///
+public sealed class MessageSanitizationResult
+{
+ private MessageSanitizationResult(
+ bool allowed,
+ IList? messages,
+ string? blockReason,
+ int redactionCount,
+ IReadOnlyList categories)
+ {
+ Allowed = allowed;
+ Messages = messages;
+ BlockReason = blockReason;
+ RedactionCount = redactionCount;
+ Categories = categories;
+ }
+
+ /// Whether the call may proceed.
+ public bool Allowed { get; }
+
+ /// Sanitized messages when allowed.
+ public IList? Messages { get; }
+
+ /// Block reason when not allowed.
+ public string? BlockReason { get; }
+
+ /// Number of redactions applied (never includes content).
+ public int RedactionCount { get; }
+
+ /// Redaction/block categories (e.g. email, api-key).
+ public IReadOnlyList Categories { get; }
+
+ /// Creates an allowed result.
+ public static MessageSanitizationResult Allow(
+ IList messages,
+ int redactionCount = 0,
+ IReadOnlyList? categories = null) =>
+ new(true, messages, null, redactionCount, categories ?? Array.Empty());
+
+ /// Creates a blocked result.
+ public static MessageSanitizationResult Block(string reason, IReadOnlyList? categories = null) =>
+ new(false, null, reason, 0, categories ?? Array.Empty());
+}
+
+///
+/// Sanitizes outbound messages for a destination target.
+///
+public interface IChatMessageSanitizer
+{
+ /// Sanitizes all outbound messages according to the target's disposition.
+ MessageSanitizationResult Sanitize(
+ IList messages,
+ string targetKey,
+ SanitizeDisposition disposition,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs b/src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs
new file mode 100644
index 00000000..02b5dd7b
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/IChatTargetAccessPolicy.cs
@@ -0,0 +1,12 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Evaluates whether a caller may invoke a given target/model.
+///
+public interface IChatTargetAccessPolicy
+{
+ ///
+ /// Returns true when the invocation is permitted.
+ ///
+ bool IsAllowed(string? callerIdentity, string targetKey, string? modelId, out string? denyReason);
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs b/src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs
new file mode 100644
index 00000000..5be65096
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/ITargetSanitizePolicy.cs
@@ -0,0 +1,28 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Per-target governance settings resolved for sanitization middleware.
+///
+public interface ITargetSanitizePolicy
+{
+ /// Resolves the sanitize disposition for a target key.
+ SanitizeDisposition GetDisposition(string targetKey);
+}
+
+///
+/// Default dispositions: local pass-through; cloud block-on-secret / redact-on-PII.
+///
+public sealed class DefaultTargetSanitizePolicy : ITargetSanitizePolicy
+{
+ ///
+ public SanitizeDisposition GetDisposition(string targetKey)
+ {
+ if (targetKey.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase))
+ {
+ return SanitizeDisposition.BlockOnSecretRedactOnPii;
+ }
+
+ // local:* and peer:* — pass by default (policy packs can override later).
+ return SanitizeDisposition.Pass;
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs b/src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs
new file mode 100644
index 00000000..799bcd7a
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/InMemoryChatInvocationAuditor.cs
@@ -0,0 +1,21 @@
+using System.Collections.Concurrent;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// In-memory auditor for tests and hosts that have not wired trust audit yet.
+///
+public sealed class InMemoryChatInvocationAuditor : IChatInvocationAuditor
+{
+ private readonly ConcurrentQueue _records = new();
+
+ /// Snapshot of recorded audits (newest last).
+ public IReadOnlyList Records => _records.ToArray();
+
+ ///
+ public void Record(ChatInvocationAuditRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ _records.Enqueue(record);
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs b/src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs
new file mode 100644
index 00000000..023d250c
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/NexoGovernanceChatClientBuilderExtensions.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Fixed-order governance composition for Nexo chat clients.
+///
+public static class NexoGovernanceChatClientBuilderExtensions
+{
+ ///
+ /// Applies PolicyGate → Sanitizing → Auditing around the inner provider client.
+ /// Hosts must use this extension so middleware cannot be mis-ordered.
+ ///
+ public static ChatClientBuilder UseNexoGovernance(this ChatClientBuilder builder, string targetKey)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetKey);
+
+ // First Use = outermost → PolicyGate runs first.
+ builder.Use((inner, sp) =>
+ {
+ var policy = sp.GetRequiredService();
+ var auditor = sp.GetRequiredService();
+ return new PolicyGateChatClient(inner, policy, auditor, targetKey);
+ });
+
+ builder.Use((inner, sp) =>
+ {
+ var sanitizer = sp.GetRequiredService();
+ var sanitizePolicy = sp.GetRequiredService();
+ var auditor = sp.GetRequiredService();
+ return new SanitizingChatClient(inner, sanitizer, sanitizePolicy, auditor, targetKey);
+ });
+
+ builder.Use((inner, sp) =>
+ {
+ var auditor = sp.GetRequiredService();
+ return new AuditingChatClient(inner, auditor, targetKey);
+ });
+
+ return builder;
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs b/src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs
new file mode 100644
index 00000000..bb5b8bdf
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/PolicyGateChatClient.cs
@@ -0,0 +1,76 @@
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Outermost middleware: denies targets not permitted by the access policy.
+///
+public sealed class PolicyGateChatClient : DelegatingChatClient
+{
+ private readonly IChatTargetAccessPolicy _policy;
+ private readonly IChatInvocationAuditor _auditor;
+ private readonly string _targetKey;
+ private readonly Func? _callerIdentityAccessor;
+
+ /// Creates a policy gate around an inner client.
+ public PolicyGateChatClient(
+ IChatClient innerClient,
+ IChatTargetAccessPolicy policy,
+ IChatInvocationAuditor auditor,
+ string targetKey,
+ Func? callerIdentityAccessor = null)
+ : base(innerClient)
+ {
+ _policy = policy ?? throw new ArgumentNullException(nameof(policy));
+ _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor));
+ _targetKey = targetKey ?? throw new ArgumentNullException(nameof(targetKey));
+ _callerIdentityAccessor = callerIdentityAccessor;
+ }
+
+ /// Target key this gate protects.
+ public string TargetKey => _targetKey;
+
+ ///
+ public override Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureAllowed(options);
+ return base.GetResponseAsync(messages, options, cancellationToken);
+ }
+
+ ///
+ public override IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureAllowed(options);
+ return base.GetStreamingResponseAsync(messages, options, cancellationToken);
+ }
+
+ private void EnsureAllowed(ChatOptions? options)
+ {
+ var caller = _callerIdentityAccessor?.Invoke();
+ if (_policy.IsAllowed(caller, _targetKey, options?.ModelId, out var reason))
+ {
+ return;
+ }
+
+ _auditor.Record(new ChatInvocationAuditRecord
+ {
+ TargetKey = _targetKey,
+ ModelId = options?.ModelId,
+ Outcome = "denied",
+ PolicyDecisions = new[] { $"target={_targetKey}", "decision=deny" },
+ ReasonCode = "target_denied",
+ });
+
+ throw new PolicyViolationException(
+ code: "target_denied",
+ message: reason ?? $"Target '{_targetKey}' is not permitted.",
+ targetKey: _targetKey,
+ modelId: options?.ModelId);
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs b/src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs
new file mode 100644
index 00000000..6c3c19ac
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/PolicyViolationException.cs
@@ -0,0 +1,34 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Thrown when policy denies a model invocation or sanitization blocks the request.
+///
+public sealed class PolicyViolationException : Exception
+{
+ /// Creates a structured policy violation.
+ public PolicyViolationException(
+ string code,
+ string message,
+ string? targetKey = null,
+ string? modelId = null,
+ IReadOnlyDictionary? details = null)
+ : base(message)
+ {
+ Code = code;
+ TargetKey = targetKey;
+ ModelId = modelId;
+ Details = details ?? new Dictionary();
+ }
+
+ /// Machine-readable reason code (e.g. target_denied, pii_blocked).
+ public string Code { get; }
+
+ /// Target key that was evaluated, when known.
+ public string? TargetKey { get; }
+
+ /// Model id that was evaluated, when known.
+ public string? ModelId { get; }
+
+ /// Additional structured details (never contains raw secret/PII content).
+ public IReadOnlyDictionary Details { get; }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs b/src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs
new file mode 100644
index 00000000..6a321405
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/SanitizationCallContext.cs
@@ -0,0 +1,15 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Flows the latest sanitization summary across the governance stack for the current async call.
+///
+internal static class SanitizationCallContext
+{
+ private static readonly AsyncLocal Current = new();
+
+ public static MessageSanitizationResult? Result
+ {
+ get => Current.Value;
+ set => Current.Value = value;
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs b/src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs
new file mode 100644
index 00000000..17fa931e
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/SanitizeDisposition.cs
@@ -0,0 +1,19 @@
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// How outbound content is treated for a given target.
+///
+public enum SanitizeDisposition
+{
+ /// Do not alter outbound content (typical for air-gapped/local-only targets).
+ Pass = 0,
+
+ /// Redact PII/secrets and continue.
+ Redact = 1,
+
+ /// Block the call when secrets are found; redact PII otherwise (strict cloud default).
+ BlockOnSecretRedactOnPii = 2,
+
+ /// Block the call when any PII or secret is found.
+ BlockOnPiiOrSecret = 3,
+}
diff --git a/src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs b/src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs
new file mode 100644
index 00000000..9bb95c52
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Governance/SanitizingChatClient.cs
@@ -0,0 +1,91 @@
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+
+namespace Nexo.AI.Pipeline.Governance;
+
+///
+/// Sanitizes outbound messages before delegation. Streaming responses pass through untouched.
+///
+public sealed class SanitizingChatClient : DelegatingChatClient
+{
+ private readonly IChatMessageSanitizer _sanitizer;
+ private readonly ITargetSanitizePolicy _policy;
+ private readonly IChatInvocationAuditor _auditor;
+ private readonly string _targetKey;
+
+ /// Creates a sanitizing decorator.
+ public SanitizingChatClient(
+ IChatClient innerClient,
+ IChatMessageSanitizer sanitizer,
+ ITargetSanitizePolicy policy,
+ IChatInvocationAuditor auditor,
+ string targetKey)
+ : base(innerClient)
+ {
+ _sanitizer = sanitizer ?? throw new ArgumentNullException(nameof(sanitizer));
+ _policy = policy ?? throw new ArgumentNullException(nameof(policy));
+ _auditor = auditor ?? throw new ArgumentNullException(nameof(auditor));
+ _targetKey = targetKey ?? throw new ArgumentNullException(nameof(targetKey));
+ }
+
+ /// Last sanitization summary for the current call path (for auditing).
+ public MessageSanitizationResult? LastResult { get; private set; }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ var sanitized = SanitizeOrThrow(messages, cancellationToken);
+ return await base.GetResponseAsync(sanitized, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var sanitized = SanitizeOrThrow(messages, cancellationToken);
+ await foreach (var update in base.GetStreamingResponseAsync(sanitized, options, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ private IList SanitizeOrThrow(
+ IEnumerable messages,
+ CancellationToken cancellationToken)
+ {
+ var list = messages as IList ?? messages.ToList();
+ var disposition = _policy.GetDisposition(_targetKey);
+ var result = _sanitizer.Sanitize(list, _targetKey, disposition, cancellationToken);
+ LastResult = result;
+ SanitizationCallContext.Result = result;
+
+ if (!result.Allowed)
+ {
+ _auditor.Record(new ChatInvocationAuditRecord
+ {
+ TargetKey = _targetKey,
+ Outcome = "denied",
+ PolicyDecisions = new[] { $"target={_targetKey}", "decision=sanitize_block" },
+ RedactionCount = 0,
+ RedactionCategories = result.Categories,
+ ReasonCode = "sanitization_blocked",
+ });
+
+ throw new PolicyViolationException(
+ code: "sanitization_blocked",
+ message: result.BlockReason ?? "Request blocked by sanitization policy.",
+ targetKey: _targetKey,
+ details: result.Categories.Count == 0
+ ? null
+ : new Dictionary { ["categories"] = string.Join(',', result.Categories) });
+ }
+
+ return result.Messages!;
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs
new file mode 100644
index 00000000..996c738d
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/MeaiPipelineOptions.cs
@@ -0,0 +1,44 @@
+namespace Nexo.AI.Pipeline;
+
+///
+/// Configuration for the Microsoft.Extensions.AI pipeline.
+/// Bound from Nexo:Meai (and feature flag Nexo:UseMeaiPipeline).
+///
+public sealed class MeaiPipelineOptions
+{
+ /// Configuration section for nested MEAI options.
+ public const string SectionName = "Nexo:Meai";
+
+ /// Feature-flag configuration key.
+ public const string FeatureFlagKey = "Nexo:UseMeaiPipeline";
+
+ /// Environment variable that enables the MEAI pipeline when set to 1 or true.
+ public const string FeatureFlagEnvVar = "NEXO_USE_MEAI_PIPELINE";
+
+ /// Ollama base URL (default localhost:11434).
+ public string OllamaBaseUrl { get; set; } = "http://localhost:11434";
+
+ /// Default Ollama model id.
+ public string OllamaModel { get; set; } = "llama3.1:latest";
+
+ ///
+ /// Path to a GGUF model for the local:onnx (LLamaSharp) target.
+ /// Falls back to NEXO_LOCAL_MODEL_PATH when unset.
+ ///
+ public string? LocalModelPath { get; set; }
+
+ /// Context size for LLamaSharp local inference.
+ public int LocalContextSize { get; set; } = 2048;
+
+ /// Max tokens for LLamaSharp local inference.
+ public int LocalMaxTokens { get; set; } = 4096;
+
+ /// AWS Bedrock tiered cloud targets.
+ public BedrockMeaiOptions Bedrock { get; set; } = new();
+
+ ///
+ /// Explicit cloud target keys the default access policy should allow
+ /// (e.g. cloud:bedrock:balanced). Empty = cloud denied.
+ ///
+ public List AllowedCloudTargets { get; set; } = new();
+}
diff --git a/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs
new file mode 100644
index 00000000..9901200b
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/MeaiPipelineServiceCollectionExtensions.cs
@@ -0,0 +1,347 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using Nexo.AI.Pipeline.Clients;
+using Nexo.AI.Pipeline.Embeddings;
+using Nexo.AI.Pipeline.Governance;
+using Nexo.AI.Pipeline.Rag;
+using Nexo.AI.Pipeline.Routing;
+
+namespace Nexo.AI.Pipeline;
+
+///
+/// DI registration for the MEAI chat pipeline (feature-flagged).
+///
+public static class MeaiPipelineServiceCollectionExtensions
+{
+ ///
+ /// Returns true when the MEAI pipeline should be registered.
+ /// Phase 6+: defaults to true. Opt out with config false/0
+ /// or env NEXO_USE_MEAI_PIPELINE=0|false.
+ ///
+ public static bool IsMeaiPipelineEnabled(IConfiguration? configuration, bool? explicitEnable = null)
+ {
+ if (explicitEnable.HasValue)
+ {
+ return explicitEnable.Value;
+ }
+
+ if (configuration is not null)
+ {
+ var flagged = configuration[MeaiPipelineOptions.FeatureFlagKey];
+ if (!string.IsNullOrWhiteSpace(flagged))
+ {
+ if (bool.TryParse(flagged, out var parsed))
+ {
+ return parsed;
+ }
+
+ if (string.Equals(flagged, "1", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(flagged, "0", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+ }
+
+ var env = Environment.GetEnvironmentVariable(MeaiPipelineOptions.FeatureFlagEnvVar);
+ if (string.Equals(env, "0", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(env, "false", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (string.Equals(env, "1", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(env, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // Phase 6 default: MEAI pipeline on.
+ return true;
+ }
+
+ ///
+ /// Registers keyed local (and optional Bedrock) governed pipelines plus an auditing router.
+ /// Raw provider clients are never registered in DI.
+ ///
+ public static IServiceCollection AddNexoMeaiPipeline(
+ this IServiceCollection services,
+ IConfiguration? configuration = null,
+ Action? configure = null,
+ Func? ollamaInnerFactory = null,
+ Func? onnxInnerFactory = null,
+ Func? bedrockInnerFactory = null,
+ bool registerDefaultRouter = true)
+ {
+ var options = new MeaiPipelineOptions();
+ if (configuration is not null)
+ {
+ BindSection(configuration.GetSection(MeaiPipelineOptions.SectionName), options);
+ }
+
+ configure?.Invoke(options);
+ ApplyBedrockAllowListDefaults(options);
+ services.AddSingleton(Options.Create(options));
+
+ RegisterGovernanceDefaults(services, options);
+ RegisterRoutingDefaults(services);
+ services.TryAddSingleton();
+
+ Func defaultOllama = sp =>
+ new OllamaHttpChatClient(sp.GetRequiredService>());
+ Func defaultOnnx = sp =>
+ new LlamaSharpChatClient(sp.GetRequiredService>());
+
+ services.AddKeyedChatClient(
+ MeaiTargetKeys.LocalOllama,
+ sp => (ollamaInnerFactory ?? defaultOllama)(sp))
+ .UseNexoGovernance(MeaiTargetKeys.LocalOllama);
+
+ services.AddKeyedChatClient(
+ MeaiTargetKeys.LocalOnnx,
+ sp => (onnxInnerFactory ?? defaultOnnx)(sp))
+ .UseNexoGovernance(MeaiTargetKeys.LocalOnnx);
+
+ if (options.Bedrock.Enabled)
+ {
+ RegisterBedrockTier(services, options, bedrockInnerFactory);
+ }
+
+ RegisterVectorDataRag(services);
+
+ if (registerDefaultRouter)
+ {
+ services.AddChatClient(sp =>
+ {
+ var router = ActivatorUtilities.CreateInstance(sp);
+ var auditor = sp.GetRequiredService();
+ return new AuditingChatClient(router, auditor, RoutingChatClient.RouterTargetKey);
+ });
+ }
+
+ return services;
+ }
+
+ ///
+ /// Registers VectorData RAG (in-process store + governed embeddings). Legacy RAG remains until Phase 6.
+ ///
+ public static IServiceCollection RegisterVectorDataRag(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp =>
+ {
+ var store = sp.GetRequiredService();
+ return store.GetCollection(VectorDataRagService.DefaultCollectionName);
+ });
+ services.TryAddSingleton>>(sp =>
+ {
+ // Same stack as chat governance: Sanitizing → Auditing → provider.
+ // Auditing must sit inside Sanitizing so SanitizationCallContext is visible
+ // while Emit runs (AsyncLocal does not flow back to an outer awaiter).
+ IEmbeddingGenerator> inner = new TokenHashEmbeddingGenerator();
+ var sanitizer = sp.GetRequiredService();
+ var auditor = sp.GetRequiredService();
+ inner = new AuditingEmbeddingGenerator(inner, auditor);
+ inner = new SanitizingEmbeddingGenerator(inner, sanitizer, SanitizeDisposition.Redact);
+ return inner;
+ });
+ services.TryAddSingleton();
+ return services;
+ }
+
+ ///
+ /// Registers a governed keyed for an additional target.
+ ///
+ public static ChatClientBuilder AddNexoGovernedChatClient(
+ this IServiceCollection services,
+ string targetKey,
+ Func innerFactory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetKey);
+ RegisterGovernanceDefaults(services);
+ return services.AddKeyedChatClient(targetKey, innerFactory)
+ .UseNexoGovernance(targetKey);
+ }
+
+ /// Registers default governance services if not already present.
+ public static IServiceCollection RegisterGovernanceDefaults(this IServiceCollection services) =>
+ RegisterGovernanceDefaults(services, options: null);
+
+ /// Registers default governance services, applying cloud allow-list from options.
+ public static IServiceCollection RegisterGovernanceDefaults(
+ this IServiceCollection services,
+ MeaiPipelineOptions? options)
+ {
+ services.TryAddSingleton(_ =>
+ {
+ var policy = new DefaultChatTargetAccessPolicy();
+ if (options?.AllowedCloudTargets is { Count: > 0 } allowed)
+ {
+ foreach (var key in allowed)
+ {
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ policy.AllowedCloudTargets.Add(key.Trim());
+ }
+ }
+ }
+
+ return policy;
+ });
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ return services;
+ }
+
+ /// Registers default routing services if not already present.
+ public static IServiceCollection RegisterRoutingDefaults(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ return services;
+ }
+
+ private static void RegisterBedrockTier(
+ IServiceCollection services,
+ MeaiPipelineOptions options,
+ Func? bedrockInnerFactory)
+ {
+ RegisterTier(
+ services,
+ DefaultRouteCandidateTable.CloudBedrockFast,
+ options.Bedrock.FastModelId,
+ bedrockInnerFactory);
+ RegisterTier(
+ services,
+ DefaultRouteCandidateTable.CloudBedrockBalanced,
+ options.Bedrock.BalancedModelId,
+ bedrockInnerFactory);
+ RegisterTier(
+ services,
+ DefaultRouteCandidateTable.CloudBedrockHeavy,
+ options.Bedrock.HeavyModelId,
+ bedrockInnerFactory);
+ }
+
+ private static void RegisterTier(
+ IServiceCollection services,
+ string targetKey,
+ string modelId,
+ Func? bedrockInnerFactory)
+ {
+ services.AddKeyedChatClient(
+ targetKey,
+ sp =>
+ {
+ if (bedrockInnerFactory is not null)
+ {
+ return bedrockInnerFactory(sp, modelId);
+ }
+
+ return sp.GetRequiredService().Create(modelId);
+ })
+ .UseNexoGovernance(targetKey);
+ }
+
+ private static void ApplyBedrockAllowListDefaults(MeaiPipelineOptions options)
+ {
+ if (!options.Bedrock.Enabled)
+ {
+ return;
+ }
+
+ void Add(string key)
+ {
+ if (!options.AllowedCloudTargets.Contains(key, StringComparer.OrdinalIgnoreCase))
+ {
+ options.AllowedCloudTargets.Add(key);
+ }
+ }
+
+ Add(DefaultRouteCandidateTable.CloudBedrockFast);
+ Add(DefaultRouteCandidateTable.CloudBedrockBalanced);
+ Add(DefaultRouteCandidateTable.CloudBedrockHeavy);
+ }
+
+ private static void BindSection(IConfiguration section, MeaiPipelineOptions options)
+ {
+ var ollamaBase = section["OllamaBaseUrl"];
+ if (!string.IsNullOrWhiteSpace(ollamaBase))
+ {
+ options.OllamaBaseUrl = ollamaBase;
+ }
+
+ var ollamaModel = section["OllamaModel"];
+ if (!string.IsNullOrWhiteSpace(ollamaModel))
+ {
+ options.OllamaModel = ollamaModel;
+ }
+
+ var localPath = section["LocalModelPath"];
+ if (!string.IsNullOrWhiteSpace(localPath))
+ {
+ options.LocalModelPath = localPath;
+ }
+
+ if (int.TryParse(section["LocalContextSize"], out var ctx) && ctx > 0)
+ {
+ options.LocalContextSize = ctx;
+ }
+
+ if (int.TryParse(section["LocalMaxTokens"], out var max) && max > 0)
+ {
+ options.LocalMaxTokens = max;
+ }
+
+ var bedrock = section.GetSection("Bedrock");
+ if (bedrock.Exists())
+ {
+ if (bool.TryParse(bedrock["Enabled"], out var enabled))
+ {
+ options.Bedrock.Enabled = enabled;
+ }
+
+ var region = bedrock["Region"];
+ if (!string.IsNullOrWhiteSpace(region))
+ {
+ options.Bedrock.Region = region;
+ }
+
+ var fast = bedrock["FastModelId"];
+ if (!string.IsNullOrWhiteSpace(fast))
+ {
+ options.Bedrock.FastModelId = fast;
+ }
+
+ var balanced = bedrock["BalancedModelId"];
+ if (!string.IsNullOrWhiteSpace(balanced))
+ {
+ options.Bedrock.BalancedModelId = balanced;
+ }
+
+ var heavy = bedrock["HeavyModelId"];
+ if (!string.IsNullOrWhiteSpace(heavy))
+ {
+ options.Bedrock.HeavyModelId = heavy;
+ }
+ }
+
+ var allowed = section.GetSection("AllowedCloudTargets").GetChildren();
+ foreach (var child in allowed)
+ {
+ if (!string.IsNullOrWhiteSpace(child.Value))
+ {
+ options.AllowedCloudTargets.Add(child.Value.Trim());
+ }
+ }
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/MeaiTargetKeys.cs b/src/Nexo.AI.Pipeline/MeaiTargetKeys.cs
new file mode 100644
index 00000000..33b874a4
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/MeaiTargetKeys.cs
@@ -0,0 +1,16 @@
+namespace Nexo.AI.Pipeline;
+
+///
+/// Well-known keyed DI service keys for MEAI chat targets.
+///
+public static class MeaiTargetKeys
+{
+ /// Local Ollama HTTP target.
+ public const string LocalOllama = "local:ollama";
+
+ ///
+ /// Local offline target. Product key retains local:onnx for policy continuity;
+ /// the implementation is LLamaSharp + GGUF (see migration notes).
+ ///
+ public const string LocalOnnx = "local:onnx";
+}
diff --git a/src/Nexo.AI.Pipeline/Models/MeaiBackedModel.cs b/src/Nexo.AI.Pipeline/Models/MeaiBackedModel.cs
new file mode 100644
index 00000000..7f3a2e13
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Models/MeaiBackedModel.cs
@@ -0,0 +1,156 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Nexo.Abstractions;
+using Nexo.AI.Pipeline.Routing;
+
+namespace Nexo.AI.Pipeline.Models;
+
+///
+/// adapter over the governed MEAI (router when registered).
+/// Deterministic/offline providers are not handled here — callers (e.g. HotSwappableModel) fall back.
+///
+public sealed class MeaiBackedModel : IModel
+{
+ private static readonly HashSet DeterministicProviders = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "offline", "mock", "echo", "mock-json", "deterministic",
+ };
+
+ private readonly IChatClient _chatClient;
+ private readonly ILogger _logger;
+
+ /// Creates an MEAI-backed .
+ public MeaiBackedModel(IChatClient chatClient, ILogger logger)
+ {
+ _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task CompleteAsync(ModelInput input, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ var (provider, model, messages, capability) = Parse(input);
+ if (!string.IsNullOrWhiteSpace(provider) && DeterministicProviders.Contains(provider))
+ {
+ throw new InvalidOperationException(
+ $"Provider '{provider}' is a deterministic/offline path; MEAI chat client is not used.");
+ }
+
+ var options = new ChatOptions
+ {
+ ModelId = string.IsNullOrWhiteSpace(model) ? null : model,
+ };
+
+ if (!string.IsNullOrWhiteSpace(capability))
+ {
+ options.AdditionalProperties = new AdditionalPropertiesDictionary
+ {
+ [RouteRequestHints.CapabilityTierKey] = capability,
+ };
+ }
+
+ _logger.LogDebug(
+ "MEAI IModel complete: provider={Provider} model={Model} capability={Capability} messages={Count}",
+ provider ?? "(default)",
+ model ?? "(default)",
+ capability ?? "(default)",
+ messages.Count);
+
+ var response = await _chatClient.GetResponseAsync(messages, options, ct).ConfigureAwait(false);
+ return new ModelOutput(response.Text ?? string.Empty);
+ }
+
+ private static (string? provider, string? model, IList messages, string? capability) Parse(
+ ModelInput input)
+ {
+ string? provider = null;
+ string? model = null;
+ string? capability = null;
+ var messages = new List();
+
+ foreach (var (role, content) in input.Messages)
+ {
+ if (string.Equals(role, "system", StringComparison.OrdinalIgnoreCase))
+ {
+ var systemParts = new List();
+ foreach (var line in (content ?? string.Empty).Split('\n'))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.StartsWith("nexo.model.provider=", StringComparison.OrdinalIgnoreCase))
+ {
+ provider = trimmed["nexo.model.provider=".Length..].Trim();
+ continue;
+ }
+
+ if (trimmed.StartsWith("nexo.model.name=", StringComparison.OrdinalIgnoreCase))
+ {
+ model = trimmed["nexo.model.name=".Length..].Trim();
+ continue;
+ }
+
+ if (trimmed.StartsWith("nexo.model.capability=", StringComparison.OrdinalIgnoreCase))
+ {
+ capability = trimmed["nexo.model.capability=".Length..].Trim();
+ continue;
+ }
+
+ if (trimmed.StartsWith("nexo.model.prefer=", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ systemParts.Add(line);
+ }
+
+ var systemText = string.Join('\n', systemParts).Trim();
+ if (!string.IsNullOrEmpty(systemText))
+ {
+ messages.Add(new ChatMessage(ChatRole.System, systemText));
+ }
+ }
+ else if (string.Equals(role, "assistant", StringComparison.OrdinalIgnoreCase))
+ {
+ messages.Add(new ChatMessage(ChatRole.Assistant, content ?? string.Empty));
+ }
+ else
+ {
+ messages.Add(new ChatMessage(ChatRole.User, content ?? string.Empty));
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(capability) && !string.IsNullOrWhiteSpace(provider))
+ {
+ capability = MapProviderToCapability(provider);
+ }
+
+ if (messages.Count == 0)
+ {
+ messages.Add(new ChatMessage(ChatRole.User, string.Empty));
+ }
+
+ return (provider, model, messages, capability);
+ }
+
+ private static string? MapProviderToCapability(string provider)
+ {
+ if (provider.Contains("bedrock", StringComparison.OrdinalIgnoreCase)
+ || provider.Contains("cloud", StringComparison.OrdinalIgnoreCase))
+ {
+ if (provider.Contains("fast", StringComparison.OrdinalIgnoreCase))
+ {
+ return "fast";
+ }
+
+ if (provider.Contains("heavy", StringComparison.OrdinalIgnoreCase))
+ {
+ return "heavy";
+ }
+
+ return "balanced";
+ }
+
+ return null;
+ }
+}
diff --git a/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj b/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj
new file mode 100644
index 00000000..7c85b434
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Nexo.AI.Pipeline.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net8.0;net9.0
+ 12.0
+ enable
+ enable
+ true
+ true
+ true
+ $(NoWarn);1591
+ Nexo.AI.Pipeline
+ Nexo AI Pipeline
+ Microsoft.Extensions.AI governed chat pipeline for Nexo (local Ollama, local LLamaSharp/ONNX-key target, and future cloud targets).
+ nexo;ai;meai;ichatclient;ollama;llamasharp;bedrock
+ https://github.com/IanFrelinger/Nexo
+ git
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nexo.AI.Pipeline/Rag/ChunkRecord.cs b/src/Nexo.AI.Pipeline/Rag/ChunkRecord.cs
new file mode 100644
index 00000000..9d3a54bf
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Rag/ChunkRecord.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.VectorData;
+
+namespace Nexo.AI.Pipeline.Rag;
+
+///
+/// VectorData record for indexed knowledge chunks.
+///
+public sealed class ChunkRecord
+{
+ /// Unique chunk key.
+ [VectorStoreKey]
+ public string Key { get; set; } = string.Empty;
+
+ /// Source document URI or path.
+ [VectorStoreData(IsIndexed = true)]
+ public string SourceUri { get; set; } = string.Empty;
+
+ /// Chunk text.
+ [VectorStoreData]
+ public string Text { get; set; } = string.Empty;
+
+ ///
+ /// Trust / sensitivity tier tag (e.g. Public, Internal, Confidential, Secret, TopSecret).
+ /// Must be ≤ caller tier to be returned.
+ ///
+ [VectorStoreData(IsIndexed = true)]
+ public string TrustTier { get; set; } = "Public";
+
+ /// When the chunk was indexed (UTC).
+ [VectorStoreData]
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ /// When the chunk was last updated (UTC).
+ [VectorStoreData]
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ /// Dense embedding vector.
+ [VectorStoreVector(EmbeddingDimensions)]
+ public ReadOnlyMemory Embedding { get; set; }
+
+ /// Default embedding dimensionality (matches NexoDefaults.EmbeddingDefaultDimension).
+ public const int EmbeddingDimensions = 64;
+}
diff --git a/src/Nexo.AI.Pipeline/Rag/InProcessVectorStore.cs b/src/Nexo.AI.Pipeline/Rag/InProcessVectorStore.cs
new file mode 100644
index 00000000..d0e003cd
--- /dev/null
+++ b/src/Nexo.AI.Pipeline/Rag/InProcessVectorStore.cs
@@ -0,0 +1,264 @@
+using System.Collections.Concurrent;
+using System.Linq.Expressions;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+
+namespace Nexo.AI.Pipeline.Rag;
+
+///
+/// In-process for local-first RAG (GA Abstractions only; no preview connectors).
+/// Keeps the legacy Nexo store untouched until Phase 6 cutover.
+///
+public sealed class InProcessVectorStore : VectorStore
+{
+ private readonly ConcurrentDictionary _collections =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ public override VectorStoreCollection GetCollection(
+ string name,
+ VectorStoreCollectionDefinition? definition = null)
+ {
+ if (typeof(TKey) != typeof(string) || typeof(TRecord) != typeof(ChunkRecord))
+ {
+ throw new NotSupportedException("InProcessVectorStore currently supports VectorStoreCollection only.");
+ }
+
+ var collection = (VectorStoreCollection)_collections.GetOrAdd(
+ name,
+ _ => new InProcessChunkCollection(name));
+ return collection;
+ }
+
+ ///
+ public override VectorStoreCollection