Built-In Handler
The platform's default handler. Writes idempotency records to the Process_StateLogs SQL table — append-only, scope-aware, multi-tenant, with processing locks and automatic expiry recovery.
HandlerKey and Configuration
The built-in handler is registered with HandlerKey = "built-in". It is active whenever the node config specifies handler: "built-in" (or when handler is omitted, since "built-in" is the default).
3-Project Structure
| Project | Base Path | Contents |
|---|---|---|
| BizFirst.Ai.DataStateMachine.BuiltIn.Domain | .../Ai/DataStateMachine/BizFirst.Ai.DataStateMachine.BuiltIn.Domain/ | ProcessStateLog entity, IProcessStateLogRepository, IProcessStateLogService |
| BizFirst.Ai.DataStateMachine.BuiltIn.Infrastructure | .../BizFirst.Ai.DataStateMachine.BuiltIn.Infrastructure/ | BuiltInDbContext, ProcessStateLogRepository, DependencyInjection |
| BizFirst.Ai.DataStateMachine.BuiltIn.Service | .../BizFirst.Ai.DataStateMachine.BuiltIn.Service/ | ProcessStateLogService, BuiltInDataStateMachineHandler, BuiltInDataStateMachinePlugin |
Database Schema — Process_StateLogs
The core table. Every state record is a row. Old rows are never deleted — they are marked Superseded by newer rows, giving a complete append-only history.
| Column | Type | Nullable | Notes |
|---|---|---|---|
| ID | int | No | Identity PK |
| StateLogScopeID | int | No | FK → Process_StateLogScopes |
| EntityID | int | No | The node's entity DB ID |
| EntityExecutionID | int | Yes | Set for execution-scoped records |
| ItemKey | nvarchar(500) | No | Primary business key (e.g. invoice ID) |
| SubItemKey | nvarchar(500) | Yes | Secondary discriminator |
| ItemEntityKey | nvarchar(200) | No | Entity type (e.g. "invoice") |
| SubItemEntityKey | nvarchar(200) | Yes | Sub-entity type |
| TenantID | int | No | Multi-tenant isolation |
| SourceAppID | int | Yes | Originating app |
| Status | int | No | FK → Process_StateLogIdempotencyStatuses |
| Data | nvarchar(max) | Yes | JSON data envelope |
| LockedUntil | datetime2 | Yes | Processing lock expiry (null when not locked) |
| SupersededByID | int | Yes | FK self-ref → newer row for same key |
| Created | datetime2 | No | Row creation timestamp |
| Modified | datetime2 | No | Last update timestamp |
| Deleted | bit | No | Soft-delete flag (0 = active) |
Lookup Tables
Two seed tables provide human-readable lookups:
| ID | ScopeCode | Description |
|---|---|---|
| 1 | App | Application-wide |
| 2 | Process | Workflow definition |
| 3 | Thread | Workflow thread |
| 4 | Element | Node definition (default) |
| 5 | ProcessExecution | Single workflow run |
| 6 | ThreadExecution | Single thread run |
| 7 | ElementExecution | Single node run |
| ID | StatusCode | Terminal |
|---|---|---|
| 0 | None | — |
| 1 | Processed | Yes |
| 2 | Processing | No |
| 3 | Failed | No |
| 4 | Skipped | Yes |
| 5 | Superseded | Yes |
| 6 | Expired | Yes |
Handler Operations
The handler implements all 7 IDataStateMachineHandler methods. The most important are TryAcquireLockAsync, SetAsync, and UpsertAsync.
public async Task<bool> TryAcquireLockAsync(StateHandlerContext ctx, CancellationToken ct)
{
var current = await _repo.GetCurrentStatusAsync(ctx, ct);
// Already terminal — do not acquire
if (current is IdempotencyStatus.Processed or IdempotencyStatus.Skipped)
return false;
// Already locked by another — do not acquire
if (current is IdempotencyStatus.Processing)
return false;
// Insert Processing record with 30-minute lock
await _repo.InsertAsync(
BuildEntity(ctx, IdempotencyStatus.Processing,
status: "Processing",
data: null,
lockedUntil: DateTime.UtcNow.AddMinutes(30)),
ct);
return true;
}
public async Task UpsertAsync(StateHandlerContext ctx, string? data, CancellationToken ct)
{
// Insert new row with current data
var newRow = BuildEntity(ctx, IdempotencyStatus.Processed,
status: "Processed", data: data, lockedUntil: null);
int newId = await _repo.InsertAsync(newRow, ct);
// Mark all prior rows for this key as Superseded
await _repo.MarkSupersededAsync(ctx, newRowId: newId, ct);
}
private static ProcessStateLog BuildEntity(
StateHandlerContext ctx, IdempotencyStatus idempotencyStatus,
string status, string? data, DateTime? lockedUntil, string? substatus = null)
=> new()
{
StateLogScopeID = ctx.StateLogScopeID,
EntityID = ctx.EntityID,
EntityExecutionID = ctx.EntityExecutionID,
ItemKey = ctx.ItemKey,
SubItemKey = ctx.SubItemKey,
ItemEntityKey = ctx.ItemEntityKey,
SubItemEntityKey = ctx.SubItemEntityKey,
TenantID = ctx.TenantID,
SourceAppID = ctx.SourceAppID ?? 0,
Status = (int)idempotencyStatus,
Data = data,
LockedUntil = lockedUntil
};
Processing Lock — Concurrency and Recovery
The 30-minute lock window protects against concurrent duplicate processing. When TryAcquireLockAsync inserts a Processing record, any other call for the same key finds it and returns false (lock not acquired).
If the node crashes, times out, or the process restarts mid-execution, the Processing record stays in the database. CatchupWorkerService runs on a schedule and resets all records where Status = Processing AND LockedUntil < UtcNow to Failed. The record is then available for retry.
| Scenario | Result | Recovery |
|---|---|---|
| Node completes successfully | Status = Processed, LockedUntil = null | No recovery needed — terminal |
| Node throws exception | ReleaseLockAsFailedAsync → Status = Failed | Manual retry or workflow retry policy |
| Process restarts mid-lock | Status = Processing (stale) | CatchupWorker resets to Failed after 30 min |
| Two threads race | Second TryAcquireLock returns false | Second thread skips — first completes normally |
Standard Data Envelope
By convention, data stored in the Data column (JSON) follows this envelope structure:
{
"metadata": {
"nodeType": "InvoiceDispatchExecutor",
"elementKey": "dispatch-invoice",
"executionId": 98231,
"runAt": "2026-05-31T14:32:11Z",
"version": "1.0",
"handlerKey": "built-in"
},
"idempotencyInfo": {
"itemKey": "INV-2024-00892",
"itemEntityKey": "invoice",
"scope": "Element",
"status": "Processed"
},
"data": {
"dispatchedAt": "2026-05-31T14:32:11Z",
"apiResponse": { }
}
}
SetAsync/UpsertAsync.