hexasync.workflows.approval 2604.29.1-pre.2
hexasync.workflows.approval
Workflow extension that adds human approval gates to hexasync.workflows. Wraps the standalone HexaSync Approval Service so workflows can pause for review, capture per-stage progress, and resume on decision.
This library is the consumer-facing surface — you reference it from your own service and wire it into your hexasync.workflows setup. The actual approval API runs in hexasync.approval.api; this package is just the bridge.
Contents
- Install
- Prerequisites
- Getting Started — six-step setup
- Quick Start: C# Workflow
- Quick Start: YAML Workflow
- Stage Tracking with
.ApprovalStages(...) - Granular DI Methods
- Advanced: Sub-process Workflows
- Advanced: Multi-Approval Workflows
- Configuration
- Troubleshooting
- Architecture
Install
dotnet add package hexasync.workflows.approval
The package transitively brings hexasync.workflows and hexasync.services.proxy. You also need:
Microsoft.EntityFrameworkCore(any provider — Postgres recommended for production parity with the approval API)Npgsql.EntityFrameworkCore.PostgreSQL(production) orMicrosoft.EntityFrameworkCore.Sqlite(tests)
Prerequisites
Before consuming this package, your service must already have:
hexasync.workflowswired viaservices.AddHexasyncWorkflows<TDbContext>(configuration)(the engine, job service, background loop, watchdog).- A concrete
DbContextimplementingIWorkflowDbContext(the workflow library's persistence interface). - Redis or another
IDistributedLockProviderregistered (the approval flush service uses leader election). - The
APPROVAL_SERVICE_URLenvironment variable pointed at a reachable approval API.
Getting Started
Six steps: package → DbContext → migration → DI → environment → workflow.
1. Add the package reference
dotnet add package hexasync.workflows.approval
2. Implement IApprovalDbContext on your existing DbContext
The library defines the entity (ApprovalWorkflowStageBinding) and the interface (IApprovalDbContext); your service supplies a concrete DbContext. Same pattern as IWorkflowDbContext.
using hexasync.workflows.EF;
using hexasync.workflows.approval.EF;
using hexasync.workflows.approval.EF.Models;
using Microsoft.EntityFrameworkCore;
public class MyDbContext : DbContext, IWorkflowDbContext, IApprovalDbContext
{
// ... your existing DbSets ...
// From IWorkflowDbContext
public DbSet<WorkflowJob> WorkflowJobs { get; set; } = null!;
public DbSet<WorkflowLog> WorkflowLogs { get; set; } = null!;
public DbSet<DbWorkflowDefinition> WorkflowDefinitions { get; set; } = null!;
public DbSet<WorkflowCallStackFrame> CallStackFrames { get; set; } = null!;
// From IApprovalDbContext (new)
public DbSet<ApprovalWorkflowStageBinding> ApprovalWorkflowStages { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ... your existing entity configuration ...
// Registers the approval-stage-bindings entity (indexes, FK, defaults).
modelBuilder.ApplyApprovalConfiguration();
}
}
ApplyApprovalConfiguration() registers the table name, three indexes (workflow_job_id, unique approval_request_id, composite stack lookup), now() defaults on timestamps, and a Restrict FK to workflow_jobs.id.
3. Author and apply an EF migration
The library ships no migrations — schema lives in your repo, owned by your migration history. Mirrors the hexasync.workflows library/consumer split.
cd path/to/your-api-project
dotnet ef migrations add Add_ApprovalWorkflowStages
dotnet ef database update
4. Wire DI in Program.cs
Two lines:
using hexasync.workflows.approval;
using hexasync.workflows.approval.EF;
// Route IApprovalDbContext to your concrete DbContext (mirrors the IWorkflowDbContext pattern).
builder.Services.AddScoped<IApprovalDbContext>(sp => sp.GetRequiredService<MyDbContext>());
// Bundle: registers approval steps + state provider + 4 hooks + flush service.
builder.Services.AddApprovalWorkflowSteps();
AddApprovalWorkflowSteps() is a one-liner. If you need finer control (e.g., custom flush, hooks-only telemetry deployment), see Granular DI Methods.
5. Set the APPROVAL_SERVICE_URL environment variable
APPROVAL_SERVICE_URL=https://approval.internal/api/approvals
The library's inner-service factory uses this URL to call the approval API.
6. Author a workflow that uses approval:request + approval:wait
See Quick Start: C# Workflow or Quick Start: YAML Workflow.
Quick Start: C# Workflow
using hexasync.workflows.approval.Steps;
using hexasync.workflows.approval.Extensions; // brings .ApprovalStages(...)
using hexasync.workflows.Builder;
using hexasync.workflows.Core;
public class DeployClusterInput
{
public string ClusterName { get; set; } = "";
public string Region { get; set; } = "";
public Guid RequesterUserId { get; set; }
}
public class DeployClusterWorkflow : IWorkflowDefinition<DeployClusterInput>
{
public string Name => "deploy-cluster";
public string DisplayName => "Deploy Cluster (with approval)";
public string Description => "Requests approval, waits for decision, deploys.";
public WorkflowDefinition Build(WorkflowBuilder b)
{
// 1. Request approval — seeds 2 stages, both pending; the first will be promoted to current.
b.Step<RequestApprovalStep>("APPROVE")
.Input<RequestApprovalParams>(ctx =>
{
var input = ctx.GetContextInput<DeployClusterInput>();
return new RequestApprovalParams(
WorkflowJobId: ctx.Job.Id,
RequesterUserId: input.RequesterUserId,
WorkflowType: "deploy-cluster",
Title: $"Deploy {input.ClusterName} to {input.Region}",
Resources: new(), // populate with your resource resolver config
RequesterContext: null,
// Optional — see "Optional: surface stages on the create-request HTTP call" below.
WorkflowProgress: ctx.GetApprovalStagesForCreate()
);
})
.ApprovalStages([
("Review", "pending"),
("Deploy", "pending"),
])
.Next("WAIT");
// 2. Yield until the approval reaches a terminal status.
b.Step<WaitForApprovalStep>("WAIT")
.Input<WaitForApprovalParams>(ctx => new WaitForApprovalParams(
ApprovalRequestId: ctx.GetStepOutput<RequestApprovalOutput>("APPROVE")!.ApprovalRequestId,
PollIntervalSeconds: 30))
.Next("DEPLOY");
// 3. The actual work — declares a transition on the "Deploy" stage when this step succeeds.
b.Step<DeployClusterStep>("DEPLOY")
.Input<DeployClusterParams>(ctx =>
{
var input = ctx.GetContextInput<DeployClusterInput>();
return new DeployClusterParams(input.ClusterName, input.Region);
})
.ApprovalStages("Deploy", "completed")
.Next("END");
b.Step<EndStep>("END");
return b.Build();
}
}
Key points:
using hexasync.workflows.approval.Extensions;brings the.ApprovalStages(...)fluent methods ANDctx.GetApprovalStagesForCreate()into scope.WaitForApprovalStepyields the workflow until the approval reachesApproved/Rejected/Expired/Cancelled. The engine resumes when polling sees a terminal status. The step always returnsSuccessfor terminal — rejection is a valid business outcome, not an engine error. Branch onWaitForApprovalOutput.FinalStatusif you need to skip downstream work on rejection.- The
RequestApprovalStep's output (ApprovalRequestId) is read by the next step viactx.GetStepOutput<RequestApprovalOutput>("APPROVE").
Optional: surface stages on the create-request HTTP call
By default, an approval:request step submits the create-request payload with WorkflowProgress: null. The ApprovalRegistrationHook writes the binding row separately, and ApprovalStageFlushService (5-minute leader-elected scan) eventually pushes stages to the API. The approval lands in the UI with no stages, then they back-fill on the next flush tick — visibly empty for up to 5 minutes.
If immediate-stage UX matters, wire WorkflowProgress: ctx.GetApprovalStagesForCreate() in the input closure (as shown in the C# Quick Start sample above). It reads the same .ApprovalStages([...]) declaration the registration hook uses — single source of truth, no duplication.
.Input<RequestApprovalParams>(ctx => new RequestApprovalParams(
/* ... */,
WorkflowProgress: ctx.GetApprovalStagesForCreate() // ← reads .ApprovalStages([...]) declaration
))
.ApprovalStages([("Review", "pending"), ("Deploy", "pending")])
GetApprovalStagesForCreate projects the declaration into the approval-API wire shape (List<WorkflowProgressStep>) with the same first-pending-→-current normalization the registration hook applies, so the API and the binding row land with matching state. Returns null when the step has no .ApprovalStages(...) declaration — fall through to the default flush-eventually path.
The hook still writes the binding row regardless. The flush service still picks it up and pushes via BulkUpdateWorkflowProgressAsync — but that's idempotent (replaces stages by approval_request_id), so the second push is harmless. If telemetry later shows the second push is meaningful cost, file a follow-up to suppress it.
YAML workflows: no parallel today. YAML authors needing immediate stages declare workflowProgress: explicitly in the approval:request step's input block. Filed for follow-up if a real consumer surfaces.
Quick Start: YAML Workflow
Same workflow in YAML — approvalStages: is the namespaced YAML keyword (registered by AddApprovalSteps()):
name: deploy-cluster
displayName: Deploy Cluster (with approval)
description: Requests approval, waits for decision, deploys.
input:
clusterName: ""
region: ""
requesterUserId: ""
steps:
APPROVE:
provider: approval:request
input:
workflowJobId: "{{ _job.id }}"
requesterUserId: "{{ _contextInput.requesterUserId }}"
workflowType: deploy-cluster
title: "Deploy {{ _contextInput.clusterName }} to {{ _contextInput.region }}"
approvalStages:
- name: Review
status: pending
- name: Deploy
status: pending
next: WAIT
WAIT:
provider: approval:wait
input:
approvalRequestId: "{{ _outputs.APPROVE.approvalRequestId }}"
pollIntervalSeconds: 30
next: DEPLOY
DEPLOY:
provider: deploy:cluster
input:
clusterName: "{{ _contextInput.clusterName }}"
region: "{{ _contextInput.region }}"
approvalStages:
- name: Deploy
status: completed
next: END
END:
provider: end
YAML expression namespaces (per hexasync.workflows):
_contextInput.field— current frame's user input POCO (camelCase)._outputs.STEP_NAME.field— completed step outputs._job.id,_job.createdBy, etc. — read-only job projection._env.HSS_KEY— environment variables.
Stage Tracking with .ApprovalStages(...)
Stages are how the workflow tells the approval API "the user wants to track these named milestones." The approval UI renders them; users see live progress.
Two roles, one API surface
The same .ApprovalStages(...) declaration plays two roles depending on which step it's attached to:
| Step | Role | What the hook does |
|---|---|---|
approval:request step |
Seed — initial stage list | ApprovalRegistrationHook writes a binding row with these stages; first pending → current |
| Any other step | Transition — merge updates | ApprovalTransitionHook looks up the binding by (jobId, forApprovalStepName) (or single-binding if no discriminator), merges the matching stage by name, promotes next pending to current |
Same syntax, runtime decides intent based on ctx.Config.ProviderId.
Status string values
Five lowercase strings, baked into the approval API wire format — do not change:
ApprovalStageReport.Pending // "pending"
ApprovalStageReport.Current // "current"
ApprovalStageReport.Completed // "completed"
ApprovalStageReport.Failed // "failed"
ApprovalStageReport.Skipped // "skipped"
Why ApprovalStages (not bare Stages)?
The C# fluent method is .ApprovalStages(...) and the YAML keyword is approvalStages: — namespaced because future extensions might need a generic stages keyword (deployment-stages, release-stages, CI/CD-stages all use the term). The destination key inside the metadata bag stays "approval:stages" — that's the wire format the approval API observes.
If a CMS workflow today calls .Stages(...) (the legacy library API that was deleted in pre.28), update it: .Stages(...) → .ApprovalStages(...) and YAML stages: → approvalStages:.
Granular DI Methods
AddApprovalWorkflowSteps() is a bundle that calls four granular methods. Use the granular methods directly only when opting out of one part:
| Method | Registers |
|---|---|
AddApprovalSteps() |
approval:request + approval:wait keyed steps + IApprovalService factory + AddYamlExtensionProperty(...) for approvalStages: (rich descriptor with JSON Schema for designer tooling discovery — pre.31 / library Story 20.3) |
AddApprovalStateProvider() |
IApprovalStateProvider → ApprovalStateProvider (scoped) |
AddApprovalHooks() |
The 4 lifecycle hooks (ApprovalRegistrationHook, ApprovalTransitionHook, ApprovalFramePopCascadeHook, ApprovalCompletionCascadeHook) |
AddApprovalFlushService() |
ApprovalStageFlushService as IHostedService (leader-elected, drain-until-empty per tick, batch 1000, 5-min interval) |
Scenarios
// Default — most consumers
services.AddApprovalWorkflowSteps();
// Integration tests — disable the flush service so tests don't push state to a real approval API
services.AddApprovalSteps()
.AddApprovalStateProvider()
.AddApprovalHooks();
// Custom state provider (e.g., redis-cached read path)
services.AddApprovalSteps()
.AddApprovalHooks()
.AddApprovalFlushService();
services.AddScoped<IApprovalStateProvider, MyCustomProvider>();
// Builder UI host that needs step keys present at compile-time but no runtime wiring
services.AddApprovalSteps();
Designer Tooling Discovery
After wiring the package, your service exposes a registry endpoint that designer tooling (workflow visualizers, low-code builders) can hit to discover the installed extension properties — including their YAML keyword, ExtensionData destination, JSON Schema for value shape, and human-readable metadata.
AddApprovalSteps() contributes a YamlExtensionPropertyDescriptor for approvalStages with:
| Field | Value |
|---|---|
yamlKey |
"approvalStages" |
extensionDataKey |
"approval:stages" |
title |
"Approval Stages" |
description |
One-liner explaining seed-vs-transition role |
valueSchema |
JSON Schema for [{ name: string, status: enum["pending","current","completed","failed","skipped"] }] |
appliesToProviderIds |
null — any provider (seed on approval:request, transition on any non-approval step) |
appliesToStepKinds |
null — any kind |
To expose it, wire the registry endpoint in your Program.cs alongside the other workflow registry endpoints:
app.MapWorkflowRegistryEndpoint();
app.MapWorkflowResourceProviderRegistryEndpoint();
app.MapWorkflowExtensionPropertiesEndpoint(); // exposes the descriptor list
All three registry endpoints apply .RequireAuthorization() by default (library Story 20.3, pre.31). To deliberately expose publicly (rare — registry contents are fingerprintable in multi-tenant settings), pass configure: r => r.AllowAnonymous(). To layer additional policies, pass configure: r => r.RequireAuthorization("AdminPolicy").
The endpoint returns a JSON array; designer tooling renders schema-driven forms from valueSchema rather than hardcoding extension-specific UI.
Advanced: Sub-process Workflows
hexasync.workflows supports sub-process workflows (push/pop call stack). Approval bindings created in a parent frame survive sub-process pops because they're keyed by approval_request_id in approval_workflow_stages, not stored on the frame's context.
Failure cascade scoping
When a sub-process fails, two cascade hooks fire in sequence:
ApprovalFramePopCascadeHook— fires per popped frame on the failure unwind. CallsCascadeFailureAtAsync(jobId, ctx.Frame.StackLevel)— flips bindings at that specific stack level only. Parent frames at lower stack levels are untouched.ApprovalCompletionCascadeHook— fires once at workflow termination ifctx.CompletionType == Failed. CallsCascadeFailureAsync(jobId)— flips ALL non-terminal bindings unconditionally.
Net effect: idempotent. Bindings already Failed from step 1 are unchanged in step 2. State is correct.
Critical: cascades only fire on terminal failure, not on retry-scheduled failures (FailedWithRetry). This fixes the legacy "flip-flop bug" where retry-eligible crashes stuck stages in failed state mid-retry.
Cross-frame transition lookup
A non-approval step in a sub-process can declare .ApprovalStages("Deploy", "completed") against a binding seeded in the parent frame. The transition hook's lookup is keyed by approval_request_id (or by forApproval step name in multi-approval workflows), not by frame identity. This was a bug in the pre-Epic-18 engine; the new design fixes it by side-effect.
Advanced: Multi-Approval Workflows
A single workflow with multiple approval:request steps requires the object-shape YAML for transitions:
DEPLOY:
provider: deploy:cluster
input:
# ...
approvalStages:
forApproval: APPROVE_PROD # the step name of the approval:request whose binding to update
transitions:
- name: Deploy
status: completed
Without the forApproval discriminator, multi-approval workflows can't unambiguously identify which binding to transition — ApprovalTransitionHook logs a warning and skips.
The C# fluent path does not currently support the object shape for transitions in Epic 8 — multi-approval is a YAML-only feature pending consumer demand.
Configuration
| Variable | Required | Purpose |
|---|---|---|
APPROVAL_SERVICE_URL |
Yes | Approval API base URL (e.g., https://approval.internal/api/approvals) |
The library reads APPROVAL_SERVICE_URL via the standard hexasync.services.proxy IMicroServiceFactory ECDSA inner-service resolver. Authentication uses ECDSA service tokens (provided by your existing AddEcdsaInnerServiceResolver registration in hexasync.workflows setup).
Tuning (compile-time constants in ApprovalStageFlushService)
| Constant | Default | Override |
|---|---|---|
BatchLimit |
1000 | Fork the service if you need a different value |
ScanInterval |
5 minutes | Same |
The drain-until-empty inner loop converts a 5-minute hook-write burst into a steady drain at the cost of one extra empty scan per tick.
Troubleshooting
LogWarning: ApprovalRegistrationHook: step 'X' succeeded but did not emit a valid ApprovalRequestId
Your custom approval-request-like step doesn't emit a RequestApprovalOutput with an ApprovalRequestId : Guid. Use the bundled RequestApprovalStep rather than rolling your own.
LogWarning: ApprovalTransitionHook: step 'X' declared transitions but no binding found
Either:
- The workflow has no
approval:requeststep. The.ApprovalStages(...)declaration is silently dropped. - The
forApprovalstep name doesn't match any registered binding (typo, or step ordering — registration runs first, so the binding must exist before the transition step runs).
LogWarning: workflow has N approvals but flat-array stages shape used
Multi-approval workflow with a transition declared as a flat array. Switch to the object shape: { forApproval: "STEP_NAME", transitions: [...] }.
LogWarning: YAML key 'stages' is not registered by any installed extension — skipped
Pre-Epic-20 YAML using bare stages: against a post-pre.30 library. Either rename the YAML to approvalStages: (recommended) or register the legacy keyword: services.AddYamlExtensionMapping("stages", "approval:stages") (not recommended — collides with future extensions).
Hooks not firing in tests
Hooks declare SupportedRunModes => WorkflowRunMode.Persisted. Inline engine.RunAsync(definition, input) calls skip-with-log. Use the persisted-mode test harness with a real WorkflowJob row.
42703 column does not exist on stage_update_required
Your migration history is pre-Epic-18 (the column was orphaned by pre.28). Generate a migration to drop the column or include it in Add_ApprovalWorkflowStages.
Architecture
The library is composed of:
- Steps (
approval:request,approval:wait) — the consumer-facing keyedIWorkflowStepimplementations. - State provider (
IApprovalStateProvider) — reads/writesapproval_workflow_stages. Owns the merge-and-promote algorithm and a transient-retry wrapper (3 attempts at 100/300/800 ms on Postgres SQLSTATE40P01/40001/08006). - Hooks — four observers on workflow lifecycle events:
ApprovalRegistrationHook(IStepCompletedHook) — seeds binding onapproval:requestsuccess.ApprovalTransitionHook(IStepCompletedHook) — applies.ApprovalStages(...)transitions on non-approval steps.ApprovalFramePopCascadeHook(IFramePoppedHook) — sub-process failure cascade scoped by stack level.ApprovalCompletionCascadeHook(IWorkflowCompletedHook) — terminal-failure cascade across all bindings.
- Flush service (
ApprovalStageFlushService) — leader-electedBackgroundService. Scansapproval_workflow_stagesfor rows wherestate_sent_to_approval_service_atis null or older thanstage_updated_at, batches them into aBulkUpdateWorkflowProgressAsynccall to the approval API, marks sent. Drain-until-empty per tick.
All hooks declare SupportedRunModes => WorkflowRunMode.Persisted — they require a real persisted WorkflowJob row to write FK-bearing data.
The state provider is scoped, the hooks are singleton observers that resolve the provider via ctx.Scope.GetRequiredService<IApprovalStateProvider>() per-event.
See also
approval-service(this repo's API) — the standalone HTTP service that backsAPPROVAL_SERVICE_URL. See the top-level README.hexasync.workflows— the underlying workflow engine. Concepts referenced here (IStepContext,WorkflowJobView,IWorkflowDefinition<TInput>, hook lifecycle, sub-process workflows) are documented in the engine'sREADME.mdandPRD.md.
No packages depend on hexasync.workflows.approval.
.NET 10.0
- hexasync.approval.contracts (>= 2604.29.1-pre.2)
- hexasync.services.approval (>= 2604.29.1-pre.2)
- Microsoft.EntityFrameworkCore (>= 10.0.5)
- Npgsql (>= 10.0.2)
- hexasync.services.proxy (>= 1.10.9-pre.33)
- hexasync.workflows (>= 1.10.9-pre.33)
| Version | Downloads | Last updated |
|---|---|---|
| 2604.29.1-pre.5 | 3 | 04/29/2026 |
| 2604.29.1-pre.4 | 0 | 04/29/2026 |
| 2604.29.1-pre.3 | 2 | 04/29/2026 |
| 2604.29.1-pre.2 | 2 | 04/29/2026 |
| 2604.29.1-pre.1 | 3 | 04/29/2026 |
| 2604.28.1-pre.4 | 2 | 04/29/2026 |
| 2604.28.1-pre.3 | 4 | 04/28/2026 |
| 2604.28.1-pre.2 | 2 | 04/28/2026 |
| 2604.28.1-pre.1 | 0 | 04/28/2026 |
| 2604.13.8 | 11 | 04/13/2026 |
| 2604.13.7 | 2 | 04/13/2026 |
| 2604.13.6 | 2 | 04/13/2026 |
| 2604.13.5 | 2 | 04/13/2026 |
| 2604.13.4 | 0 | 04/13/2026 |
| 2604.13.3 | 2 | 04/13/2026 |
| 2604.13.2 | 2 | 04/13/2026 |
| 2604.13.1 | 3 | 04/13/2026 |
| 2604.10.1 | 0 | 04/10/2026 |
| 1.0.0-pre.5 | 9 | 04/10/2026 |
| 1.0.0-pre.4 | 0 | 04/10/2026 |
| 1.0.0-pre.3 | 1 | 04/10/2026 |
| 1.0.0-pre.2 | 8 | 04/08/2026 |
| 1.0.0-pre.1 | 6 | 04/08/2026 |