How GuardRails Works
A four-layer architecture where a single execution context flows through a pipeline of guards — Pre, Node, Post, Error — with every phase sharing state via a metadata dictionary.
Four-Layer Architecture
GuardRails is organized into four layers, each with a clear responsibility. Higher layers depend on lower ones; lower layers have zero knowledge of higher layers.
| Layer | Project | Responsibility |
|---|---|---|
| Domain | BizFirst.Ai.GuardRails.Domain |
Pure contracts: interfaces, models, enums. Zero external dependencies. All other layers reference this. |
| Service | BizFirst.Ai.GuardRails.Service |
Orchestration, config resolution, circuit breaker, guard registry, logging. Infrastructure only — no guard business logic. |
| Providers | Provider.Core, Provider.PII |
Guard implementations. Each guard is a focused class with a single security or reliability concern. |
| Execution | BizFirst.Ai.GuardRails.Execution |
Public facade consumed by BaseNodeExecutor. Coordinates Pre/Post/Error handlers. |
The Execution Context
A single GuardRailExecutionContext instance is created at the start of each node execution and flows through all phases. Guards read from it, write to it, and use its Metadata dictionary to pass state between phases.
public class GuardRailExecutionContext
{
public long TenantId { get; set; } // multi-tenant isolation
public long UserId { get; set; } // actor performing the operation
public string OperationId { get; set; } // unique per node execution
public object? Input { get; set; } // node input data
public object? Output { get; set; } // set after node runs; guards may modify
public IDictionary<string, object?> Metadata { get; set; } // shared state across all phases
public string TraceId { get; set; } // distributed trace correlation
public string PhaseId { get; set; } // current phase name ("Pre" / "Post" / "Error")
public DateTime CreatedAt { get; set; }
}
context.Metadata["__timeout_start"] in Pre, then reads it in Post to compute elapsed time. CostTrackingGuard stores a reservation ID in Pre, confirms it in Post. This pattern keeps guards stateless at the instance level.
Execution Flow
BaseNodeExecutor.ExecuteAsync()
Entry point. Creates a GuardRailExecutionContext with TenantId, UserId, OperationId, Input from the node execution context.
Pre — IGuardRailsExecutor.ExecutePreAsync(context)
Runs all configured Pre-phase guards sequentially. Each guard calls ExecuteAsync(context, GuardRailPhase.Pre). If any returns IsAllowed=false, execution stops — the node never runs.
ExecuteInternalAsync() — node business logic runs
The actual node: HTTP call, email send, database query, AI model call. If it throws, go to Error phase.
Post — IGuardRailsExecutor.ExecutePostAsync(context, output)
Sets context.Output = output. Runs Post-phase guards. If OutputModified=true, the node's output data is replaced with the modified value (e.g., PII redacted).
Error — IGuardRailsExecutor.ExecuteOnErrorAsync(context, exception)
Only runs if the node threw. Records circuit breaker state, writes audit events. Always returns IsAllowed=true — error handlers never block.
Guard Result: GuardRailCheckResult
Every guard returns a GuardRailCheckResult. Three factory methods cover all outcomes:
| Result | IsAllowed | Meaning |
|---|---|---|
Success(outputModified, metadata) | true | Guard passed. outputModified=true signals the caller to update output data from context.Output. |
Blocked(message, retryAfter, metadata) | false | Guard blocked. Execution stops. retryAfterSeconds is optional retry hint. |
Warning(message, metadata) | true | Guard noticed something but allows. ErrorMessage is set but IsAllowed=true. Used for non-critical violations. |
Aggregated Result: GuardRailsExecutionResult
The public facade returns GuardRailsExecutionResult which aggregates all individual guard results from a phase:
public class GuardRailsExecutionResult
{
public bool IsAllowed { get; set; } // false if any guard blocked
public IList<string> ExecutedGuards { get; set; } // names of all guards that ran
public IList<GuardRailViolation> Violations { get; set; } // all block reasons
public GuardRailsExecutionTimeline Timeline { get; set; } // per-guard durations
public long TotalDurationMs { get; set; }
}
The IsSecurityCritical Flag
Each guard declares IsSecurityCritical. This controls behavior when the guard's own circuit breaker opens (i.e., the guard infrastructure itself fails):
| IsSecurityCritical | Circuit Open Behavior | Examples |
|---|---|---|
true Fail-Secure |
Block execution. Unknown state = deny. | InputValidationGuard, RateLimitingGuard, PiiDetectionGuard, PiiRedactionGuard |
false Fail-Open |
Allow execution with a warning log. | TimeoutGuard, CircuitBreakerGuard |
Guard Interface
Every guard implements IGuardRail, a composite of three focused interfaces:
// Core execution
public interface IGuardRailExecutor
{
string Name { get; } // e.g. "TimeoutGuard"
string Version { get; } // e.g. "1.0.0"
IReadOnlyList<GuardRailPhase> SupportedPhases { get; } // which phases this guard handles
bool IsSecurityCritical { get; }
Task<GuardRailCheckResult> ExecuteAsync(
GuardRailExecutionContext context,
GuardRailPhase phase,
CancellationToken cancellationToken = default);
}
// Config validation
public interface IGuardRailConfigValidator
{
bool Validate(IDictionary<string, object?> configuration);
GuardRailConfigValidationResult ValidateWithDetails(IDictionary<string, object?> configuration);
}
// Discovery / metadata
public interface IGuardRailDescriptor
{
string GuardName { get; }
string Description { get; }
string ConfigurationSchema { get; } // JSON Schema as string
IReadOnlyList<GuardRailDependency> Dependencies { get; }
}
// Composite — every guard implements all three
public interface IGuardRail : IGuardRailExecutor, IGuardRailConfigValidator, IGuardRailDescriptor { }
SetConfiguration Pattern
Guards are stateless at construction time. Configuration is injected at resolution time via:
public void SetConfiguration(IDictionary<string, object?> configuration)
{
_configuration = configuration ?? new Dictionary<string, object?>();
}
This is called by GuardConfigResolver or GuardFactory before the guard's ExecuteAsync is called. Guards read their settings from _configuration during execution, never during construction.