Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
482 changes: 482 additions & 0 deletions tutorials/workflow/csharp/history-propagation/.gitignore

Large diffs are not rendered by default.

167 changes: 167 additions & 0 deletions tutorials/workflow/csharp/history-propagation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Dapr Workflow History Propagation — Patient Intake (.NET SDK)

This example demonstrates how Dapr workflows can propagate their execution
history to child workflows and activities, so downstream consumers can
inspect the full (or partial) execution context of their caller. See the
[Workflow history propagation](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-history-propagation/)
docs for the concept overview.

## Workflow architecture

```
PatientIntake (workflow)
├── VerifyInsurance (activity, no propagation)
└── PrescribeMedication (child workflow, Lineage)
├── CheckAllergies (activity, no propagation)
├── ScreenDrugInteractions (activity, no propagation)
├── ComplianceAudit (child workflow, Lineage)
│ → sees PatientIntake + PrescribeMedication events
└── DispenseMedicationWorkflow (child workflow, OwnHistory)
→ sees PrescribeMedication events only
→ refuses to dispense if the screening lineage is missing
```

### Propagation scope

| Mode | Enum value | What it sends | Use case |
|------|-----------|---------------|----------|
| **Lineage** | `HistoryPropagationScope.Lineage` | Caller's own events + any ancestor events it received | Full chain-of-custody verification (compliance audits) |
| **Own history** | `HistoryPropagationScope.OwnHistory` | Caller's own events only (no ancestor chain) | Trust boundary — downstream only sees the immediate caller (pharmacy dispense) |

### Key demonstration

- **ComplianceAudit** receives the full lineage via `HistoryPropagationScope.Lineage` —
it verifies that `VerifyInsurance` ran in the grandparent workflow
(PatientIntake), plus `CheckAllergies` and `ScreenDrugInteractions` ran in
PrescribeMedication.

- **DispenseMedicationWorkflow** receives only PrescribeMedication's history via
`HistoryPropagationScope.OwnHistory`. The PatientIntake ancestral history is
excluded — the pharmacy system doesn't need (or get to see) the upstream
chain. Before dispensing, the pharmacy verifies that `CheckAllergies` and
`ScreenDrugInteractions` completed in the propagated history.

### Scenarios

The demo runs two scenarios back-to-back to show both the happy path and the
pharmacy's safety check:

1. **Lineage forwarded → pharmacy dispenses.** `PrescribeMedication` calls the
dispense step with `HistoryPropagationScope.OwnHistory`. The pharmacy sees
the completed allergy and interaction screens in the propagated history and
fills the prescription.

2. **Lineage withheld → pharmacy refuses.** `PrescribeMedication` calls the
dispense step **without** history propagation (simulating an upstream system
that fails to forward its lineage). With no propagated history to prove the
prescription was screened, the pharmacy refuses to dispense and returns a
`refused` result explaining what was missing.

## .NET API surface

```csharp
// Parent workflow — propagate Lineage when calling a child workflow
var result = await ctx.CallChildWorkflowAsync<T>(
nameof(ComplianceAuditWorkflow),
input,
new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.Lineage));

// Parent workflow — propagate OwnHistory when calling a child workflow
var dispense = await ctx.CallChildWorkflowAsync<T>(
nameof(DispenseMedicationWorkflow),
input,
new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.OwnHistory));

// Child workflow — read the propagated history
var history = ctx.GetPropagatedHistory(); // returns PropagatedHistory?

if (history is not null)
{
// Filter to a specific ancestor workflow by name
var prescribeEntries = history.FilterByWorkflowName(nameof(PrescribeMedicationWorkflow));

// Inspect events within that ancestor's segment
var completedCount = prescribeEntries.Entries[0].Events
.Count(e => e.Kind == HistoryEventKind.TaskCompleted);
}
```

Key types in `Dapr.Workflow`:
- `HistoryPropagationScope` — enum: `None`, `OwnHistory`, `Lineage`
- `ChildWorkflowTaskOptions` — pass `PropagationScope` here
- `PropagatedHistory` — call `.FilterByWorkflowName(name)`, `.FilterByAppId(id)`, `.FilterByInstanceId(id)`
- `PropagatedHistoryEntry` — has `WorkflowName`, `AppId`, `InstanceId`, `Events`
- `PropagatedHistoryEvent` — has `EventId`, `Kind` (`HistoryEventKind`), `Timestamp`
- `HistoryEventKind` — enum including `TaskScheduled`, `TaskCompleted`, `TaskFailed`, etc.

> **Replay safety**: workflow code runs many times during durable execution.
> Guard side-effecting calls — including `Console.WriteLine` — with
> `if (!ctx.IsReplaying)` so they only fire on the live execution, not on each
> replay.

## Running this example

1. Requires Dapr `1.18.0+` (workflow history propagation),
2. `Dapr.Workflow 1.18.0+`, and the [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
(or newer). Redis is started automatically by `dapr init`.
3. Build the example:

```bash
dotnet build ./order-processor
```

4. Run the demo:

<!-- STEP
name: Run history-propagation demo
expected_stdout_lines:
- "SCENARIO 1: lineage forwarded"
- "[ComplianceAudit] APPROVED"
- "[DispenseMedication] DISPENSED"
- "SCENARIO 2: lineage withheld"
- "[DispenseMedication] REFUSED"
- "pharmacy refused to dispense"
- "missing lineage: no propagated history received from prescriber"
output_match_mode: substring
background: false
timeout_seconds: 180
sleep: 15
-->

```bash
dapr run -f .
```

<!-- END_STEP -->

---

The app runs both scenarios once and exits on its own — no Ctrl+C needed.

In scenario 1 (lineage forwarded) you'll see the pharmacy dispense:

```
[ComplianceAudit] Received propagated history with 2 segment(s):
[ComplianceAudit] APPROVED (risk=0.10, total events inspected=...)
[DispenseMedication] Dispense request: amoxicillin 500mg for P-1042 (propagated history: ... events)
[DispenseMedication] DISPENSED: rx-P-1042-...
```

In scenario 2 (lineage withheld) the pharmacy refuses:

```
[PrescribeMedication] Step 4: DispenseMedicationWorkflow child wf
-> NO history propagation (negative scenario)
[DispenseMedication] Dispense request: penicillin 500mg for P-2087 (propagated history: none)
[DispenseMedication] REFUSED — no propagated history; cannot verify screening for P-2087
[PrescribeMedication] Step 4 BLOCKED: pharmacy refused to dispense (missing lineage: no propagated history received from prescriber)
```

## Standalone-mode note

In standalone mode the sidecar will log `propagating unsigned workflow history
to ...` warnings — these are expected. Without `WorkflowHistorySigning` enabled,
propagated history chunks aren't cryptographically signed, which is fine for a
local `dapr run` demo. Signing the chunks within an mTLS trust boundary is a
production concern handled at the cluster/control-plane level and is out of
scope for this quickstart.
7 changes: 7 additions & 0 deletions tutorials/workflow/csharp/history-propagation/dapr.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: 1
common:
resourcesPath: ../../components
apps:
- appID: patient-intake
appDirPath: ./patient-intake/
command: ["dotnet", "run"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// ------------------------------------------------------------------------
// Copyright 2026 The Dapr Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ------------------------------------------------------------------------

namespace PatientIntake;

using Dapr.Workflow;

public sealed class VerifyInsuranceActivity : WorkflowActivity<PatientRecord, bool>
{
public override Task<bool> RunAsync(WorkflowActivityContext ctx, PatientRecord rec)
{
Console.WriteLine($" [VerifyInsurance] Checking coverage for patient {rec.PatientId}");
return Task.FromResult(true);
}
}

public sealed class CheckAllergiesActivity : WorkflowActivity<PatientRecord, bool>
{
public override Task<bool> RunAsync(WorkflowActivityContext ctx, PatientRecord rec)
{
Console.WriteLine($" [CheckAllergies] Screening {rec.PatientId} for {rec.Medication}");
return Task.FromResult(true);
}
}

public sealed class ScreenDrugInteractionsActivity : WorkflowActivity<PatientRecord, bool>
{
public override Task<bool> RunAsync(WorkflowActivityContext ctx, PatientRecord rec)
{
Console.WriteLine($" [ScreenDrugInteractions] Screening {rec.Medication} {rec.Dosage:F0}mg for {rec.PatientId}");
return Task.FromResult(true);
}
}

public sealed class DispenseMedicationActivity : WorkflowActivity<PatientRecord, DispenseResult>
{
public override Task<DispenseResult> RunAsync(WorkflowActivityContext ctx, PatientRecord rec)
{
var dispenseId = $"rx-{rec.PatientId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
Console.WriteLine($" [DispenseMedication] DISPENSED: {dispenseId}");
return Task.FromResult(new DispenseResult(
DispenseId: dispenseId,
Status: "dispensed",
EventCount: 0)); // EventCount populated by DispenseMedicationWorkflow
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// ------------------------------------------------------------------------
// Copyright 2026 The Dapr Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ------------------------------------------------------------------------

namespace PatientIntake;

public sealed record PatientRecord(
string PatientId,
string Name,
string Dob,
string Mrn,
string Condition,
string Medication,
double Dosage,
bool ForwardLineage = true);

public sealed record ComplianceResult(
bool Compliant,
double RiskScore,
string Reason,
int EventCount);

public sealed record DispenseResult(
string DispenseId,
string Status,
int EventCount,
string Reason = "");
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Dapr.Workflow" Version="1.18.0-rc01" />
<PackageReference Include="Dapr.AspNetCore" Version="1.18.0-rc01" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using Dapr.Workflow;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PatientIntake;

var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddDaprClient();
services.AddDaprWorkflow(options =>
{
options.RegisterWorkflow<PatientIntakeWorkflow>();
options.RegisterWorkflow<PrescribeMedicationWorkflow>();
options.RegisterWorkflow<ComplianceAuditWorkflow>();
options.RegisterWorkflow<DispenseMedicationWorkflow>();

options.RegisterActivity<VerifyInsuranceActivity>();
options.RegisterActivity<CheckAllergiesActivity>();
options.RegisterActivity<ScreenDrugInteractionsActivity>();
options.RegisterActivity<DispenseMedicationActivity>();
});
});

using var host = builder.Build();

await host.StartAsync();

var workflowClient = host.Services.GetRequiredService<DaprWorkflowClient>();

// ---------------------------------------------------------------------------
// Run two scenarios back-to-back
// ---------------------------------------------------------------------------

Console.WriteLine(Banner("WORKFLOW HISTORY PROPAGATION DEMO — PATIENT INTAKE (.NET)"));
Console.WriteLine();
Console.WriteLine(" Flow: PatientIntake -> VerifyInsurance");
Console.WriteLine(" -> PrescribeMedication (child wf, Lineage)");
Console.WriteLine(" -> CheckAllergies -> ScreenDrugInteractions");
Console.WriteLine(" -> ComplianceAudit (child wf, Lineage) <-- sees PatientIntake + PrescribeMedication events");
Console.WriteLine(" -> DispenseMedicationWorkflow (child wf, OwnHistory) <-- sees only PrescribeMedication events");

// Scenario 1 (happy path): PrescribeMedication forwards its own history to the
// pharmacy, which verifies the upstream screening and dispenses.
await RunScenario(
"SCENARIO 1: lineage forwarded — pharmacy dispenses",
"intake-ok",
new PatientRecord(
PatientId: "P-1042",
Name: "Jane Doe",
Dob: "1985-06-12",
Mrn: "MRN-77231",
Condition: "bacterial sinusitis",
Medication: "amoxicillin",
Dosage: 500,
ForwardLineage: true));

// Scenario 2 (negative): PrescribeMedication dispenses WITHOUT propagating its
// history, so the pharmacy receives no lineage and refuses to dispense.
await RunScenario(
"SCENARIO 2: lineage withheld — pharmacy refuses",
"intake-missing-lineage",
new PatientRecord(
PatientId: "P-2087",
Name: "John Roe",
Dob: "1979-03-04",
Mrn: "MRN-55810",
Condition: "strep throat",
Medication: "penicillin",
Dosage: 500,
ForwardLineage: false));

Console.WriteLine();
Console.WriteLine(Banner("COMPLETE"));

await host.StopAsync();

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// Schedules one PatientIntake run, waits for it to finish, prints the final
// result, and purges its state so the demo can exit cleanly.
async Task RunScenario(string title, string instanceId, PatientRecord rec)
{
Console.WriteLine();
Console.WriteLine(Banner(title));
Console.WriteLine($" [main] Scheduling workflow instance: {instanceId}");

await workflowClient.ScheduleNewWorkflowAsync(
name: nameof(PatientIntakeWorkflow),
instanceId: instanceId,
input: rec);

var state = await workflowClient.WaitForWorkflowCompletionAsync(instanceId: instanceId);

if (state is null)
Console.WriteLine(" [main] Workflow not found!");
else if (state.RuntimeStatus == WorkflowRuntimeStatus.Completed)
Console.WriteLine($" [main] Result: {state.ReadOutputAs<string>()}");
else
Console.WriteLine($" [main] Workflow ended with status: {state.RuntimeStatus}");

await workflowClient.PurgeInstanceAsync(instanceId);
}

static string Banner(string msg)
{
var line = new string('=', msg.Length + 4);
return $"{line}\n= {msg} =\n{line}";
}
Loading
Loading