Portal Community

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
1AppApplication-wide
2ProcessWorkflow definition
3ThreadWorkflow thread
4ElementNode definition (default)
5ProcessExecutionSingle workflow run
6ThreadExecutionSingle thread run
7ElementExecutionSingle node run
ID StatusCode Terminal
0None
1ProcessedYes
2ProcessingNo
3FailedNo
4SkippedYes
5SupersededYes
6ExpiredYes

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":  { }
  }
}
The envelope structure is a convention, not enforced by the engine. Custom handlers can use any JSON structure in their data field. The built-in handler stores whatever string you pass to SetAsync/UpsertAsync.
Never store sensitive data (PII, credentials, secret tokens) in the Data column. The column is queryable by admin tooling and is part of the audit trail. Store a reference ID instead and fetch the sensitive data from a secure vault when needed.