AI Agents  

Building an AI Control Plane in C#: A Minimal, Production-Shaped Blueprint (with Code)

What this is (and what it is not)

This is a practical, code-forward blueprint for implementing an AI control plane in C# using ASP.NET Core. It focuses on the core mechanics that make agentic AI governable: run intake, executable policy decisions, entitlements, least-privilege tool invocation through a proxy, stage gates (validators), artifact versioning, and an append-only evidence ledger.

It is not a full enterprise product and it intentionally avoids vendor coupling. The design assumes you may use Azure OpenAI, OpenAI, or local models, but the control plane remains above that layer. The goal is to give you a minimal foundation that looks like real production architecture: explicit decisions, enforceable boundaries, and auditable evidence.


Architecture you will implement

Core components:

  • RunStore: persists runs and lifecycle state

  • PolicyEngine: evaluates context into machine-readable decisions

  • EntitlementsService: rate limits and budget envelopes

  • EvidenceLedger: append-only evidence events

  • ToolProxy: the enforcement choke point for tool calls

  • Validators: quality gates at stage boundaries

  • ArtifactStore: versioned deliverables and hashes

  • ControlPlaneOrchestrator: composes everything into a workflow

The architectural principle is straightforward. You can distribute execution across workers, but enforcement and evidence must remain consistent and centralized. If tools can be called outside the proxy, or if gates can be skipped, you do not have a control plane.

This implementation uses EF Core with SQLite for simplicity. Swap the provider to SQL Server or Postgres for production, and add migrations and indices appropriate to your throughput.


1) Domain models and EF Core DbContext

A control plane needs a durable record of what happened. These tables are the minimum for governance: Runs for lifecycle, EvidenceEvents for append-only audit trails, and Artifacts for versioned outputs. Everything else, like tool calls, approvals, and cost records, can be added incrementally once this core is stable.

The models below are intentionally explicit: every row has timestamps, and every evidence event stores a typed Kind plus JSON payload for extensibility. In production, you may normalize certain evidence kinds into dedicated tables for performance, but starting with a single append-only ledger is often the fastest path to a usable system.

// File: Data/ControlPlaneDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace ControlPlane.Data;

public sealed class ControlPlaneDbContext : DbContext
{
    public ControlPlaneDbContext(DbContextOptions<ControlPlaneDbContext> options) : base(options) { }

    public DbSet<RunRecord> Runs => Set<RunRecord>();
    public DbSet<EvidenceEvent> Evidence => Set<EvidenceEvent>();
    public DbSet<ArtifactRecord> Artifacts => Set<ArtifactRecord>();

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<RunRecord>().HasKey(x => x.RunId);
        b.Entity<EvidenceEvent>().HasKey(x => x.EvidenceId);
        b.Entity<ArtifactRecord>().HasKey(x => x.ArtifactId);

        b.Entity<EvidenceEvent>()
            .HasIndex(x => x.RunId);

        b.Entity<ArtifactRecord>()
            .HasIndex(x => x.RunId);
    }
}

// File: Data/RunRecord.cs
namespace ControlPlane.Data;

public sealed class RunRecord
{
    public Guid RunId { get; set; }
    public string TenantId { get; set; } = "";
    public string UserId { get; set; } = "";
    public string WorkflowKey { get; set; } = "";
    public string Environment { get; set; } = "";
    public string Status { get; set; } = "";
    public string RequestHashSha256 { get; set; } = "";
    public DateTime CreatedAtUtc { get; set; }
}

// File: Data/EvidenceEvent.cs
namespace ControlPlane.Data;

public sealed class EvidenceEvent
{
    public Guid EvidenceId { get; set; }
    public Guid RunId { get; set; }
    public string Kind { get; set; } = "";
    public string PayloadJson { get; set; } = "";
    public DateTime CreatedAtUtc { get; set; }
}

// File: Data/ArtifactRecord.cs
namespace ControlPlane.Data;

public sealed class ArtifactRecord
{
    public Guid ArtifactId { get; set; }
    public Guid RunId { get; set; }
    public string TypeKey { get; set; } = "";
    public int Version { get; set; }
    public string ContentHashSha256 { get; set; } = "";
    public string StoragePath { get; set; } = "";
    public string Classification { get; set; } = "";
    public DateTime CreatedAtUtc { get; set; }
}

2) Canonical hashing utilities

Canonical hashing is your reproducibility and integrity primitive. It lets you compute stable hashes for requests and artifacts even when JSON formatting differs. This is essential for deduplication, audit narratives, and for building tamper-evident chains later if needed.

The utility below uses JSON serialization with deterministic ordering. For strict canonical JSON, you would ensure stable ordering and formatting rules, and you would avoid floats where possible. For most enterprise control-plane use cases, stable serialization plus SHA-256 gives you the operational value you need.

// File: Services/Hashing.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace ControlPlane.Services;

public static class Hashing
{
    private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
    {
        WriteIndented = false
    };

    public static string Sha256Text(string text)
    {
        var bytes = Encoding.UTF8.GetBytes(text);
        var hash = SHA256.HashData(bytes);
        return Convert.ToHexString(hash).ToLowerInvariant();
    }

    public static string Sha256Object<T>(T obj)
    {
        var json = JsonSerializer.Serialize(obj, CanonicalJsonOptions);
        return Sha256Text(json);
    }

    public static string CanonicalJson<T>(T obj)
    {
        return JsonSerializer.Serialize(obj, CanonicalJsonOptions);
    }
}

3) Evidence ledger (append-only)

The evidence ledger is a first-class component, not a logging convenience. It produces the structured record that lets you answer “what happened” questions. It should be append-only, and it should be used consistently by every component in the execution path.

In production, you may add immutability guarantees at the database layer (no updates/deletes), or write to an event stream and sink into SQL. The interface remains the same: append evidence events as the system executes, including failures and denials.

// File: Services/EvidenceLedger.cs
using System.Text.Json;
using ControlPlane.Data;

namespace ControlPlane.Services;

public interface IEvidenceLedger
{
    Task<Guid> AppendAsync(Guid runId, string kind, object payload, CancellationToken ct);
}

public sealed class EvidenceLedger : IEvidenceLedger
{
    private readonly ControlPlaneDbContext _db;

    public EvidenceLedger(ControlPlaneDbContext db)
    {
        _db = db;
    }

    public async Task<Guid> AppendAsync(Guid runId, string kind, object payload, CancellationToken ct)
    {
        var id = Guid.NewGuid();
        var ev = new EvidenceEvent
        {
            EvidenceId = id,
            RunId = runId,
            Kind = kind,
            PayloadJson = JsonSerializer.Serialize(payload),
            CreatedAtUtc = DateTime.UtcNow
        };

        _db.Evidence.Add(ev);
        await _db.SaveChangesAsync(ct).ConfigureAwait(false);
        return id;
    }
}

4) Policy engine (executable decisions)

Policy must be executable, versioned, and recorded. A policy engine that only returns “allow/deny” is not enough. You want a full decision: allowed tools, required approvals, tool-call limits, retention rules, and output classification. That decision becomes the contract enforced by the rest of the system.

The implementation below is intentionally deterministic and simple. In production, you can replace the internals with a rules engine or OPA/Rego, but keep the same output shape. The rest of the system should not need to change when policy logic evolves.

// File: Services/PolicyEngine.cs
namespace ControlPlane.Services;

public sealed record PolicyDecision(
    string PolicyVersion,
    IReadOnlyList<string> AllowTools,
    IReadOnlyList<string> RequireApprovalFor,
    int MaxToolCalls,
    int RetentionDays,
    string OutputClassification
);

public interface IPolicyEngine
{
    PolicyDecision Evaluate(string env, string classification, string workflowKey);
}

public sealed class PolicyEngine : IPolicyEngine
{
    public const string Version = "v1.0.0";

    public PolicyDecision Evaluate(string env, string classification, string workflowKey)
    {
        var allowTools = new List<string> { "read_repo", "search_docs" };
        var approvals = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        var maxToolCalls = env == "innovation" ? 20 : 10;
        var retentionDays = env == "innovation" ? 7 : 90;
        var outputClassification = classification;

        if (string.Equals(env, "production", StringComparison.OrdinalIgnoreCase))
        {
            approvals.Add("publish");
            approvals.Add("commit_repo");
        }

        if (string.Equals(classification, "Restricted", StringComparison.OrdinalIgnoreCase))
        {
            allowTools = new List<string> { "search_docs" };
            maxToolCalls = Math.Min(maxToolCalls, 5);
            approvals.Add("export");
            approvals.Add("send_external");
        }

        return new PolicyDecision(
            PolicyVersion: Version,
            AllowTools: allowTools,
            RequireApprovalFor: approvals.OrderBy(x => x).ToList(),
            MaxToolCalls: maxToolCalls,
            RetentionDays: retentionDays,
            OutputClassification: outputClassification
        );
    }
}

5) Entitlements (budget + throttling)

Entitlements are what allow you to scale without cost surprise. They define who can run what and within which budget envelope. The key is to resolve entitlements before execution and to record the envelope as evidence, so every run can be explained operationally and financially.

This minimal service returns a fixed entitlement. In production, you would resolve entitlements from contract plans, internal chargeback, or user roles. You would also add rate limiting and concurrent run limits, typically enforced via Redis or a database-backed semaphore.

// File: Services/EntitlementsService.cs
namespace ControlPlane.Services;

public sealed record Entitlement(int MaxRunsPerDay, decimal MaxCostUsdPerRun);

public interface IEntitlementsService
{
    Entitlement Resolve(string tenantId, string userId, string workflowKey);
}

public sealed class EntitlementsService : IEntitlementsService
{
    public Entitlement Resolve(string tenantId, string userId, string workflowKey)
    {
        return new Entitlement(MaxRunsPerDay: 50, MaxCostUsdPerRun: 2.50m);
    }
}

6) Tool proxy (least-privilege choke point)

The tool proxy is the enforcement boundary that prevents uncontrolled autonomy. If your agents can invoke tools outside this proxy, policy enforcement becomes advisory rather than real. A tool proxy also standardizes evidence capture: every tool call is recorded in the ledger with parameters (redacted as needed) and results.

This example enforces allowlisted tools and a tool-call limit. In production, you would add parameter-level constraints (for example, repo scope, filesystem sandbox, URL allowlists), secret mediation, and stronger redaction controls. The interface stays the same: CallAsync is the single path to execute tools.

// File: Services/ToolProxy.cs
using ControlPlane.Services;

namespace ControlPlane.Tools;

public sealed record ToolContext(Guid RunId, HashSet<string> AllowedTools, int RemainingCalls);

public interface ITool
{
    string Name { get; }
    Task<object> ExecuteAsync(Dictionary<string, object?> args, CancellationToken ct);
}

public interface IToolProxy
{
    Task<object> CallAsync(ToolContext ctx, string toolName, Dictionary<string, object?> args, CancellationToken ct);
}

public sealed class ToolProxy : IToolProxy
{
    private readonly IEvidenceLedger _ledger;
    private readonly Dictionary<string, ITool> _tools;

    public ToolProxy(IEvidenceLedger ledger, IEnumerable<ITool> tools)
    {
        _ledger = ledger;
        _tools = tools.ToDictionary(t => t.Name, StringComparer.OrdinalIgnoreCase);
    }

    public async Task<object> CallAsync(ToolContext ctx, string toolName, Dictionary<string, object?> args, CancellationToken ct)
    {
        if (!ctx.AllowedTools.Contains(toolName))
        {
            await _ledger.AppendAsync(ctx.RunId, "ToolDenied", new { tool = toolName, reason = "NotAllowedByPolicy" }, ct);
            throw new UnauthorizedAccessException($"Tool '{toolName}' is not allowed by policy.");
        }

        if (ctx.RemainingCalls <= 0)
        {
            await _ledger.AppendAsync(ctx.RunId, "ToolDenied", new { tool = toolName, reason = "ToolCallLimitExceeded" }, ct);
            throw new InvalidOperationException("Tool call limit exceeded.");
        }

        if (!_tools.TryGetValue(toolName, out var tool))
        {
            await _ledger.AppendAsync(ctx.RunId, "ToolDenied", new { tool = toolName, reason = "ToolNotRegistered" }, ct);
            throw new KeyNotFoundException($"Tool '{toolName}' not registered.");
        }

        await _ledger.AppendAsync(ctx.RunId, "ToolCallStarted", new { tool = toolName, args }, ct);
        var result = await tool.ExecuteAsync(args, ct).ConfigureAwait(false);
        await _ledger.AppendAsync(ctx.RunId, "ToolCallCompleted", new { tool = toolName, result }, ct);

        return result;
    }
}

Example tools:

// File: Tools/SearchDocsTool.cs
namespace ControlPlane.Tools;

public sealed class SearchDocsTool : ITool
{
    public string Name => "search_docs";

    public Task<object> ExecuteAsync(Dictionary<string, object?> args, CancellationToken ct)
    {
        var q = args.TryGetValue("q", out var v) ? Convert.ToString(v) ?? "" : "";
        return Task.FromResult<object>(new
        {
            query = q,
            hits = new[] { new { title = "AI Control Plane Spec", score = 0.91 } }
        });
    }
}

7) Validators (quality gates)

Validators reduce rework and standardize what “good” looks like. They should run automatically at stage boundaries and produce structured findings that can be recorded as evidence. Importantly, validators should be deterministic, not probabilistic. If a validator’s output changes unpredictably, it becomes impossible to operationalize.

This minimal validator checks for required keys in the deliverable. In production, you would extend validators to include cross-artifact consistency, policy compliance checks, and readiness checks tied to your SDLC. The important pattern is that gate outcomes drive state transitions: fail gates block progress until addressed.

// File: Services/Validators.cs
namespace ControlPlane.Services;

public sealed record GateResult(bool Passed, IReadOnlyList<object> Findings);

public interface IValidator
{
    string Name { get; }
    GateResult Validate(Dictionary<string, object?> artifact);
}

public sealed class CompletenessValidator : IValidator
{
    public string Name => "CompletenessValidator";

    public GateResult Validate(Dictionary<string, object?> artifact)
    {
        var findings = new List<object>();
        var required = new[] { "title", "summary", "controls", "owner" };

        foreach (var k in required)
        {
            if (!artifact.TryGetValue(k, out var v) || v is null || string.IsNullOrWhiteSpace(v.ToString()))
            {
                findings.Add(new { key = k, issue = "Missing" });
            }
        }

        return new GateResult(findings.Count == 0, findings);
    }
}

8) Artifact store (versioned outputs)

Artifacts are the unit of enterprise AI delivery. They must be versioned, hashed, and associated with run provenance. Without artifacts, you have chat transcripts and ad hoc documents. With artifacts, you have a governable deliverable pipeline that can be reviewed, approved, and audited.

This implementation stores JSON artifacts to disk and tracks metadata in the database. In production, move content to object storage and keep metadata in SQL. The pattern remains: compute a content hash, bump version per run/type, persist, then emit evidence.

// File: Services/ArtifactStore.cs
using ControlPlane.Data;
using System.Text;

namespace ControlPlane.Services;

public interface IArtifactStore
{
    Task<object> SaveAsync(Guid runId, string typeKey, string classification, string contentJson, CancellationToken ct);
}

public sealed class ArtifactStore : IArtifactStore
{
    private readonly ControlPlaneDbContext _db;

    public ArtifactStore(ControlPlaneDbContext db)
    {
        _db = db;
    }

    public async Task<object> SaveAsync(Guid runId, string typeKey, string classification, string contentJson, CancellationToken ct)
    {
        var maxVersion = _db.Artifacts
            .Where(a => a.RunId == runId && a.TypeKey == typeKey)
            .Select(a => (int?)a.Version)
            .Max() ?? 0;

        var nextVersion = maxVersion + 1;
        var contentHash = Hashing.Sha256Text(contentJson);

        Directory.CreateDirectory("artifacts");
        var path = Path.Combine("artifacts", $"{runId}_{typeKey}_v{nextVersion}.json");
        await File.WriteAllTextAsync(path, contentJson, Encoding.UTF8, ct).ConfigureAwait(false);

        var rec = new ArtifactRecord
        {
            ArtifactId = Guid.NewGuid(),
            RunId = runId,
            TypeKey = typeKey,
            Version = nextVersion,
            ContentHashSha256 = contentHash,
            StoragePath = path,
            Classification = classification,
            CreatedAtUtc = DateTime.UtcNow
        };

        _db.Artifacts.Add(rec);
        await _db.SaveChangesAsync(ct).ConfigureAwait(false);

        return new
        {
            artifactId = rec.ArtifactId,
            type = rec.TypeKey,
            version = rec.Version,
            hash = rec.ContentHashSha256,
            path = rec.StoragePath
        };
    }
}

9) Orchestrator (staged workflow example)

The orchestrator composes the system. It creates the run, resolves entitlements, evaluates policy, builds a tool context, performs tool calls through the proxy, constructs a deliverable, validates it, stores it, and updates run status. This is the minimal “governed pipeline” pattern.

In production, the orchestrator becomes a DAG/stage engine with retries, node state, parallelism, and approvals. The interface remains similar: you pass context, it returns a run id and status. The important point is that the orchestrator is the only place that can transition the workflow state, ensuring gates and evidence cannot be bypassed.

// File: Services/ControlPlaneOrchestrator.cs
using ControlPlane.Data;
using ControlPlane.Tools;

namespace ControlPlane.Services;

public interface IControlPlaneOrchestrator
{
    Task<object> RunAsync(string tenantId, string userId, string workflowKey, string env, string classification, object payload, CancellationToken ct);
}

public sealed class ControlPlaneOrchestrator : IControlPlaneOrchestrator
{
    private readonly ControlPlaneDbContext _db;
    private readonly IEvidenceLedger _ledger;
    private readonly IEntitlementsService _entitlements;
    private readonly IPolicyEngine _policy;
    private readonly IToolProxy _tools;
    private readonly IValidator _validator;
    private readonly IArtifactStore _artifacts;

    public ControlPlaneOrchestrator(
        ControlPlaneDbContext db,
        IEvidenceLedger ledger,
        IEntitlementsService entitlements,
        IPolicyEngine policy,
        IToolProxy tools,
        IValidator validator,
        IArtifactStore artifacts)
    {
        _db = db;
        _ledger = ledger;
        _entitlements = entitlements;
        _policy = policy;
        _tools = tools;
        _validator = validator;
        _artifacts = artifacts;
    }

    public async Task<object> RunAsync(string tenantId, string userId, string workflowKey, string env, string classification, object payload, CancellationToken ct)
    {
        var runId = Guid.NewGuid();
        var requestHash = Hashing.Sha256Object(payload);

        _db.Runs.Add(new RunRecord
        {
            RunId = runId,
            TenantId = tenantId,
            UserId = userId,
            WorkflowKey = workflowKey,
            Environment = env,
            Status = "Created",
            RequestHashSha256 = requestHash,
            CreatedAtUtc = DateTime.UtcNow
        });
        await _db.SaveChangesAsync(ct).ConfigureAwait(false);

        await _ledger.AppendAsync(runId, "RunCreated", new { workflowKey, env, requestHash }, ct);

        var ent = _entitlements.Resolve(tenantId, userId, workflowKey);
        await _ledger.AppendAsync(runId, "EntitlementResolved", ent, ct);

        var decision = _policy.Evaluate(env, classification, workflowKey);
        await _ledger.AppendAsync(runId, "PolicyDecision", decision, ct);

        var ctx = new ToolContext(runId, decision.AllowTools.ToHashSet(StringComparer.OrdinalIgnoreCase), decision.MaxToolCalls);

        try
        {
            await _tools.CallAsync(ctx, "search_docs", new Dictionary<string, object?> { ["q"] = "AI control plane policy engine" }, ct);
        }
        catch (Exception ex)
        {
            await _ledger.AppendAsync(runId, "ToolCallError", new { error = ex.Message }, ct);
        }

        var artifact = new Dictionary<string, object?>
        {
            ["title"] = "AI Control Plane Blueprint",
            ["summary"] = "A minimal governed architecture for agentic AI including policy, entitlements, audit, and quality gates.",
            ["controls"] = new Dictionary<string, object?>
            {
                ["policy"] = "Executable policy decisions with versioning",
                ["entitlements"] = "Budget envelopes and rate limits",
                ["audit"] = "Append-only evidence ledger",
                ["tools"] = "Least-privilege tool proxy",
                ["quality"] = "Stage gates and validators"
            },
            ["owner"] = new { tenantId, userId }
        };

        var gate = _validator.Validate(artifact);
        await _ledger.AppendAsync(runId, "GateResult", new { gate = _validator.Name, gate.Passed, gate.Findings }, ct);

        if (!gate.Passed)
        {
            var rr = await _db.Runs.FindAsync(new object[] { runId }, ct).ConfigureAwait(false);
            if (rr is not null) rr.Status = "FailedGate";
            await _db.SaveChangesAsync(ct).ConfigureAwait(false);

            return new { runId, status = "FailedGate", findings = gate.Findings };
        }

        var contentJson = Hashing.CanonicalJson(artifact);
        var saved = await _artifacts.SaveAsync(runId, "control_plane_blueprint", decision.OutputClassification, contentJson, ct);
        await _ledger.AppendAsync(runId, "ArtifactSaved", saved, ct);

        var run = await _db.Runs.FindAsync(new object[] { runId }, ct).ConfigureAwait(false);
        if (run is not null) run.Status = "Completed";
        await _db.SaveChangesAsync(ct).ConfigureAwait(false);

        return new { runId, status = "Completed", artifact = saved };
    }
}

10) ASP.NET Core API entrypoint (Minimal API)

This provides the HTTP boundary and wires DI. In production, identity should come from auth claims rather than request fields. The endpoint below keeps things explicit for readability, but the control plane pattern remains the same: the API creates a request context and invokes the orchestrator.

This is also where you would add request validation, idempotency keys, and environment-level gating. For example, you could deny “production” runs unless the caller has a specific role. Those are policy decisions too, but many enterprises enforce them at the API boundary as an additional defense.

// File: Program.cs
using ControlPlane.Data;
using ControlPlane.Services;
using ControlPlane.Tools;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ControlPlaneDbContext>(o =>
    o.UseSqlite("Data Source=control_plane.db"));

builder.Services.AddScoped<IEvidenceLedger, EvidenceLedger>();
builder.Services.AddSingleton<IEntitlementsService, EntitlementsService>();
builder.Services.AddSingleton<IPolicyEngine, PolicyEngine>();
builder.Services.AddScoped<IValidator, CompletenessValidator>();
builder.Services.AddScoped<IArtifactStore, ArtifactStore>();

builder.Services.AddSingleton<ITool, SearchDocsTool>();
builder.Services.AddScoped<IToolProxy, ToolProxy>();

builder.Services.AddScoped<IControlPlaneOrchestrator, ControlPlaneOrchestrator>();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ControlPlaneDbContext>();
    db.Database.EnsureCreated();
}

app.MapPost("/runs", async (RunRequest req, IControlPlaneOrchestrator orch, CancellationToken ct) =>
{
    var result = await orch.RunAsync(req.TenantId, req.UserId, req.WorkflowKey, req.Env, req.Classification, req.Payload, ct);
    return Results.Ok(result);
});

app.Run();

public sealed record RunRequest(
    string TenantId,
    string UserId,
    string WorkflowKey,
    string Env,
    string Classification,
    object Payload
);

Run it:

dotnet new web -n ControlPlane
# Add packages:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet run

Where to extend this into real production

The first production hardening steps are predictable: replace SQLite with a real database, introduce real authentication and tenancy isolation, and ensure tool execution is sandboxed and parameter constrained. You also need to start treating evidence and artifact retention as policy-driven, not just storage defaults.

Once those are in place, you can scale capability by adding workflows and validators rather than rewriting architecture. That is the compounding benefit of a control plane: new use cases inherit enforcement and audit by default. The platform becomes a governed execution layer for agentic AI rather than a collection of disconnected “AI features.”