ProcessEngine Wiring
GuardRails integrates into the ProcessEngine at the BaseNodeExecutor level. Every node executor inherits the guard pipeline automatically — no per-node plumbing required.
Integration Architecture
The ProcessEngine uses a layered executor hierarchy. GuardRails hooks into BaseNodeExecutor — the common base class for all node executors — so every node type participates in the guard pipeline without any additional work:
| Layer | Class | GuardRails Role |
|---|---|---|
| Base | BaseNodeExecutor |
Owns the guard hook sequence: Pre → Execute → Post. Calls INodeGuardExecutor. |
| Domain Contract | INodeGuardExecutor |
The interface BaseNodeExecutor depends on. Three methods: ExecutePreAsync, ExecutePostAsync, ExecuteOnErrorAsync. |
| Implementation | NodeGuardExecutor (Service layer) |
Resolves config, instantiates guards, runs pipeline, returns GuardRailsExecutionResult. |
| Fallback | IRateLimitingOrchestrator |
Optional complementary tier for rate limiting; pluggable alongside or instead of GuardRails when INodeGuardExecutor is not configured. |
The Guard Hook Sequence
BaseNodeExecutor is organized as a partial class. The guard hooks are in the guard-specific partial file and called from the main execution flow:
OnPreGuardRails(context)
Constructs GuardRailExecutionContext from the node's NodeExecutionContext. Calls INodeGuardExecutor.ExecutePreAsync(guardContext). If IsAllowed=false, raises GuardRailBlockedException — the node never runs.
Node executes
The node's own ExecuteAsync() runs normally. The guard context is held in scope — Metadata persists across phases.
OnPostGuardRails(context, nodeResult)
Calls INodeGuardExecutor.ExecutePostAsync(guardContext, nodeResult.OutputData). If OutputModified=true, replaces nodeResult.OutputData with the guard-modified output. If IsAllowed=false, raises exception.
OnErrorGuardRails(context, exception) (on exception)
Called from the catch block when the node throws. Calls ExecuteOnErrorAsync. Error-phase guards observe and log — they never block execution. The original exception propagates normally.
Context Construction
GuardRailExecutionContext is built from the node's NodeExecutionContext at the start of OnPreGuardRails:
var guardContext = new GuardRailExecutionContext
{
TenantId = context.TenantId,
UserId = context.UserId,
NodeId = context.NodeId,
NodeType = context.NodeType,
ExecutionId = context.ExecutionId,
TraceId = context.TraceId,
SourceIp = context.SourceIp,
Input = context.InputData,
Output = null, // populated after node runs, before Post phase
Metadata = new Dictionary<string, object?>() // persists across phases
};
guardContext instance is passed to Pre, Post, and Error phases. Guards that store state in context.Metadata (e.g., __timeout_start in TimeoutGuard) can read it back in later phases. This enables cross-phase correlation without shared mutable fields on the guard class.
OutputModified Propagation
When a Post-phase guard modifies the output (e.g., PiiRedactionGuard scrubs an email address), it sets OutputModified=true in the GuardRailCheckResult. BaseNodeExecutor checks this and replaces the node's output:
// In BaseNodeExecutor.OnPostGuardRails():
var postResult = await _guardExecutor.ExecutePostAsync(guardContext, nodeResult.OutputData);
if (postResult.OutputModified)
{
// Replace node output with the guard-modified version
nodeResult.OutputData = guardContext.Output;
}
if (!postResult.IsAllowed)
{
throw new GuardRailBlockedException(postResult);
}
The downstream node and execution log both receive the redacted/modified version. The original node output never escapes the execution boundary.
Error Phase: Observe-Only
Error-phase guards run when the node throws an exception. They are strictly observational — they can log, update metrics, or enrich the exception with context, but they cannot block execution or swallow the exception:
// In BaseNodeExecutor, catch block:
catch (Exception ex)
{
await OnErrorGuardRails(guardContext, ex);
throw; // original exception always re-thrown
}
This guarantees that guard failures in the error phase don't create double-exception scenarios or swallow legitimate errors.
Guard Configuration Resolution
NodeGuardExecutor resolves the guard configuration for each node execution. The resolution chain:
// NodeGuardExecutor calls GuardConfigResolver for each node:
var config = await _configResolver.ResolveAsync(nodeId, tenantId);
// → checks cache (15-min TTL)
// → falls back to configured guard templates
// → returns null if no guards configured (no-op)
if (config == null) return GuardRailsExecutionResult.Empty();
Violation Audit Trail
Every time a guard returns IsAllowed=false, GuardRailsAuditLogger writes an audit event asynchronously. The audit logger batches writes so violation recording never adds latency to the execution hot path:
// GuardRailViolationAuditEvent (no Input/Output — allowlist approach)
{
OccurredAtUtc: "2026-05-25T14:32:01Z",
TenantId: 42,
UserId: 1001,
GuardName: "PiiDetectionGuard",
Phase: 0, // Pre
OperationId: "op-abc-123",
Reason: "PII detected: Email, SSN",
TraceId: "trace-xyz-789"
}
Execution Timeline in Results
GuardRailsExecutionResult carries a GuardRailsExecutionTimeline showing guard-by-guard forensics. This is returned to the caller and is available for debugging and compliance reporting:
// Sample timeline from execution result:
Guard Timeline:
TimeoutGuard (pre, 2ms) ✓
RateLimitingGuard (pre, 5ms) ✓
PiiDetectionGuard (pre, 12ms) ✗ [BLOCKED: SSN, Email detected]
What Happens When No Guards Are Configured
If GuardConfigResolver finds no configuration for a node, NodeGuardExecutor returns GuardRailsExecutionResult.Empty(). BaseNodeExecutor treats this as IsAllowed=true — the node runs without any guard overhead. Nodes without guard configuration are zero-cost.
Future: Octopus / Phase 6 Plan
The current integration is at the BaseNodeExecutor level (individual nodes). A planned Phase 6 extension will add guard execution at the ProcessElementExecutor level, enabling guards that span entire process steps rather than individual nodes. This will support workflow-level rate limiting and cross-node PII detection without duplicating per-node configuration.