diff --git a/workflows/csharp/sdk-context-propagation/README.md b/workflows/csharp/sdk-context-propagation/README.md new file mode 100644 index 000000000..b23b7614c --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/README.md @@ -0,0 +1,170 @@ +# Dapr Workflow — Context Propagation (.NET SDK) + +This quickstart demonstrates **workflow history propagation**, a new feature in Dapr 1.18 that lets a parent workflow share its execution history with child workflows. Downstream services can inspect that history to make trust-aware decisions — without any external state store or custom messaging. + +> **Runtime requirement**: Dapr 1.18+ ([dapr/dapr#9810](https://github.com/dapr/dapr/pull/9810)) +> **SDK requirement**: `Dapr.Workflow >= 1.18.0-rc01` ([dapr/dotnet-sdk#1802](https://github.com/dapr/dotnet-sdk/pull/1802)) +> **Proposal**: [dapr/proposals#102](https://github.com/dapr/proposals/issues/102) + +## What is workflow context propagation? + +When a parent workflow calls a child workflow it can optionally attach a tamper-evident snapshot of its own execution history. The receiver reads that snapshot via `ctx.GetPropagatedHistory()` and inspects the returned `PropagatedHistory` entries — letting it verify that the correct upstream steps ran before it proceeds. + +### Two propagation modes + +| Mode | Enum value | What the receiver sees | +|------|-----------|----------------------| +| **Own history** | `HistoryPropagationScope.OwnHistory` | Only the direct caller's events | +| **Lineage** | `HistoryPropagationScope.Lineage` | Caller's events **plus** any ancestor history the caller itself received | + +## Scenario: Patient intake / e-prescribing + +A compliance audit and a pharmacy dispense step refuse to act unless the propagated history proves the required upstream checks (insurance, allergies, drug interactions) actually ran. + +``` +PatientIntake (root) + └─ VerifyInsurance (activity, no propagation) + └─ PrescribeMedication (child wf, Lineage) + └─ CheckAllergies (activity, no propagation) + └─ ScreenDrugInteractions (activity, no propagation) + └─ ComplianceAudit (grandchild wf, Lineage) + | reads PatientIntake/VerifyInsurance + | PrescribeMedication/CheckAllergies + | PrescribeMedication/ScreenDrugInteractions + └─ DispenseMedicationWorkflow (grandchild wf, OwnHistory) + reads PrescribeMedication events only + └─ DispenseMedication (activity) +``` + +`ComplianceAudit` uses `HistoryPropagationScope.Lineage` to see the **full ancestor chain** — it can verify both the insurance check (performed by the grandparent `PatientIntake`) and the allergy/interaction checks (performed by the parent `PrescribeMedication`) before approving the prescription. + +`DispenseMedicationWorkflow` uses `HistoryPropagationScope.OwnHistory` to see only the **direct caller's events** — a trust-boundary mode that limits visibility to what `PrescribeMedication` itself executed. The pharmacy dispense system doesn't need (or get to see) the upstream patient-intake chain. + +This sample mirrors the canonical Go reference [dapr/go-sdk#823](https://github.com/dapr/go-sdk/pull/823) and the [Go quickstart](https://github.com/dapr/quickstarts/pull/1315). + +### .NET vs Python/Go difference + +The Python sibling ([dapr/quickstarts#1309](https://github.com/dapr/quickstarts/pull/1309)) and the Go reference call the final dispense step as a bare activity with an `OwnHistory` propagation argument. In the .NET SDK (v1.18) `HistoryPropagationScope` is only available on `ChildWorkflowTaskOptions` — activity calls do not carry a propagation scope. To demonstrate the identical trust-boundary semantics, this sample wraps the `DispenseMedicationActivity` inside `DispenseMedicationWorkflow` (a child workflow). + +## .NET API surface + +```csharp +// Parent workflow — propagate Lineage when calling a child workflow +var result = await ctx.CallChildWorkflowAsync( + nameof(ComplianceAuditWorkflow), + input, + new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.Lineage)); + +// Parent workflow — propagate OwnHistory when calling a child workflow +var dispense = await ctx.CallChildWorkflowAsync( + 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. + +## Prerequisites + +- [Dapr CLI](https://docs.dapr.io/getting-started/install-dapr-cli/) 1.18+ +- Dapr runtime 1.18+ initialized (`dapr init`) +- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) +- Redis (started automatically by `dapr init`) + +## Run the sample + +```sh +cd workflows/csharp/sdk-context-propagation + +dapr run -f . +``` + +## Expected output + +``` +================================================================ += WORKFLOW HISTORY PROPAGATION DEMO — PATIENT INTAKE (.NET) = +================================================================ + + Flow: PatientIntake -> VerifyInsurance + -> PrescribeMedication (child wf, Lineage) + -> CheckAllergies -> ScreenDrugInteractions + -> ComplianceAudit (child wf, Lineage) <-- sees PatientIntake + PrescribeMedication events + -> DispenseMedicationWorkflow (child wf, OwnHistory) <-- sees only PrescribeMedication events + + [main] Scheduling workflow instance: intake-001 + [PatientIntake] Starting intake for patient P-1042 + [PatientIntake] Step 1: VerifyInsurance (no propagation) + [VerifyInsurance] Checking coverage for patient P-1042 + [PatientIntake] Step 1 complete: insurance verified + [PatientIntake] Step 2: PrescribeMedication child wf (HistoryPropagationScope.Lineage) + [PrescribeMedication] Starting prescription: amoxicillin 500mg for bacterial sinusitis + [PrescribeMedication] Step 1: CheckAllergies (no propagation) + [CheckAllergies] Screening P-1042 for amoxicillin + [PrescribeMedication] Step 1 complete: allergy clear + [PrescribeMedication] Step 2: ScreenDrugInteractions (no propagation) + [ScreenDrugInteractions] Screening amoxicillin 500mg for P-1042 + [PrescribeMedication] Step 2 complete: no interactions + [PrescribeMedication] Step 3: ComplianceAudit child wf (HistoryPropagationScope.Lineage) + [ComplianceAudit] Auditing prescription for patient P-1042 + [ComplianceAudit] Received propagated history with 2 segment(s): + [ComplianceAudit] workflow: name=PatientIntakeWorkflow app=order-processor events=... + [ComplianceAudit] workflow: name=PrescribeMedicationWorkflow app=order-processor events=... + [ComplianceAudit] Verification: + PatientIntake TaskCompleted events: 1 (expect >= 1: VerifyInsurance) + PrescribeMedication TaskCompleted events: 2 (expect >= 2: CheckAllergies, ScreenDrugInteractions) + [ComplianceAudit] APPROVED (risk=0.10, total events inspected=...) + [PrescribeMedication] Step 3 complete: compliance audit passed (risk=0.10) + [PrescribeMedication] Step 4: DispenseMedicationWorkflow child wf (HistoryPropagationScope.OwnHistory) + [DispenseMedicationWorkflow] Propagated segments: 1 + [DispenseMedicationWorkflow] workflow: name=PrescribeMedicationWorkflow app=order-processor events=... + [DispenseMedicationWorkflow] event: kind=ExecutionStarted id=... + [DispenseMedicationWorkflow] event: kind=TaskScheduled id=... + [DispenseMedicationWorkflow] event: kind=TaskCompleted id=... + [DispenseMedicationWorkflow] PatientIntake in history (expected 0): 0 + [DispenseMedication] DISPENSED: rx-P-1042-... (amoxicillin 500mg) + [PrescribeMedication] Step 4 complete: dispensed (id=rx-P-1042-...) + [PrescribeMedication] COMPLETE: dispensed: id=rx-P-1042-..., patient=P-1042, drug=amoxicillin 500mg + [PatientIntake] COMPLETE: dispensed: id=rx-P-1042-..., patient=P-1042, drug=amoxicillin 500mg + [main] Workflow completed! Output: "dispensed: ..." + +================================================================ += COMPLETE = +================================================================ +``` + +## 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. + +## References + +- Sibling Python quickstart: [dapr/quickstarts#1309](https://github.com/dapr/quickstarts/pull/1309) +- Canonical Go SDK reference: [dapr/go-sdk#823](https://github.com/dapr/go-sdk/pull/823) +- Sibling Go quickstart: [dapr/quickstarts#1315](https://github.com/dapr/quickstarts/pull/1315) +- .NET SDK implementation: [dapr/dotnet-sdk#1802](https://github.com/dapr/dotnet-sdk/pull/1802) +- Runtime support: [dapr/dapr#9810](https://github.com/dapr/dapr/pull/9810) +- Docs (.NET): [dapr/docs#5174](https://github.com/dapr/docs/pull/5174) +- Proposal: [dapr/proposals#102](https://github.com/dapr/proposals/issues/102) +- 1.18 endgame: [dapr/dapr#9856](https://github.com/dapr/dapr/issues/9856) diff --git a/workflows/csharp/sdk-context-propagation/dapr.yaml b/workflows/csharp/sdk-context-propagation/dapr.yaml new file mode 100644 index 000000000..a97cac6e5 --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/dapr.yaml @@ -0,0 +1,7 @@ +version: 1 +common: + resourcesPath: ../../components +apps: + - appID: order-processor + appDirPath: ./order-processor/ + command: ["dotnet", "run"] diff --git a/workflows/csharp/sdk-context-propagation/order-processor/Activities.cs b/workflows/csharp/sdk-context-propagation/order-processor/Activities.cs new file mode 100644 index 000000000..2403890ef --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/order-processor/Activities.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------ +// 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 OrderProcessor; + +using Dapr.Workflow; + +/// +/// Verifies the patient's insurance coverage. Called by PatientIntake without +/// propagation. +/// +public sealed class VerifyInsuranceActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext ctx, PatientRecord rec) + { + Console.WriteLine($" [VerifyInsurance] Checking coverage for patient {rec.PatientId}"); + return Task.FromResult(true); + } +} + +/// +/// Screens the patient against their allergy list for the candidate drug. +/// Called by PrescribeMedication without propagation. +/// +public sealed class CheckAllergiesActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext ctx, PatientRecord rec) + { + Console.WriteLine($" [CheckAllergies] Screening {rec.PatientId} for {rec.Medication}"); + return Task.FromResult(true); + } +} + +/// +/// Screens the candidate prescription against the patient's active medication +/// list. Called by PrescribeMedication without propagation. +/// +public sealed class ScreenDrugInteractionsActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext ctx, PatientRecord rec) + { + Console.WriteLine($" [ScreenDrugInteractions] Screening {rec.Medication} {rec.Dosage:F0}mg for {rec.PatientId}"); + return Task.FromResult(true); + } +} + +/// +/// Dispenses the medication. Called by DispenseMedicationWorkflow. +/// +public sealed class DispenseMedicationActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext ctx, PatientRecord rec) + { + var dispenseId = $"rx-{rec.PatientId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + Console.WriteLine($" [DispenseMedication] DISPENSED: {dispenseId} ({rec.Medication} {rec.Dosage:F0}mg)"); + return Task.FromResult(new DispenseResult( + DispenseId: dispenseId, + Status: "dispensed", + EventCount: 0)); // EventCount populated by DispenseMedicationWorkflow + } +} diff --git a/workflows/csharp/sdk-context-propagation/order-processor/Models.cs b/workflows/csharp/sdk-context-propagation/order-processor/Models.cs new file mode 100644 index 000000000..5dd69534e --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/order-processor/Models.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// 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 OrderProcessor; + +/// +/// Patient record propagated through the workflow hierarchy. In a real +/// deployment the Name / DOB / MRN fields are protected health info and +/// would be candidates for redaction when the record is propagated downstream. +/// +/// Patient identifier. +/// Patient name. +/// Date of birth (YYYY-MM-DD). +/// Medical record number. +/// Diagnosis / indication. +/// Prescribed drug name. +/// Dosage in milligrams. +public sealed record PatientRecord( + string PatientId, + string Name, + string Dob, + string Mrn, + string Condition, + string Medication, + double Dosage); + +/// Result produced by the ComplianceAudit workflow. +/// Whether the prescription cleared compliance. +/// Risk score in the range [0, 1]. +/// Human-readable decision rationale. +/// Number of propagated history segments inspected. +public sealed record ComplianceResult( + bool Compliant, + double RiskScore, + string Reason, + int EventCount); + +/// Result produced by the DispenseMedication activity. +/// Pharmacy dispense identifier. +/// Dispense status string. +/// Number of propagated history events inspected. +public sealed record DispenseResult( + string DispenseId, + string Status, + int EventCount); diff --git a/workflows/csharp/sdk-context-propagation/order-processor/OrderProcessor.csproj b/workflows/csharp/sdk-context-propagation/order-processor/OrderProcessor.csproj new file mode 100644 index 000000000..6c373742c --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/order-processor/OrderProcessor.csproj @@ -0,0 +1,17 @@ + + + + Exe + enable + net8.0 + enable + latest + + + + + + + + + diff --git a/workflows/csharp/sdk-context-propagation/order-processor/Program.cs b/workflows/csharp/sdk-context-propagation/order-processor/Program.cs new file mode 100644 index 000000000..3fae6514c --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/order-processor/Program.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// 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. +// ------------------------------------------------------------------------ + +// Workflow History Propagation Quickstart (.NET SDK) +// +// Scenario: patient intake / e-prescribing pipeline. +// +// Flow: +// PatientIntake (root) +// └─ VerifyInsurance (activity, no propagation) +// └─ PrescribeMedication (child wf, HistoryPropagationScope.Lineage) +// └─ CheckAllergies (activity, no propagation) +// └─ ScreenDrugInteractions (activity, no propagation) +// └─ ComplianceAudit (grandchild wf, HistoryPropagationScope.Lineage) +// | reads: PatientIntake + PrescribeMedication events +// └─ DispenseMedicationWorkflow (grandchild wf, HistoryPropagationScope.OwnHistory) +// reads: PrescribeMedication events only +// └─ DispenseMedication (activity) +// +// Requires Dapr 1.18+ (dapr/dapr#9810) and Dapr.Workflow 1.18+ (dapr/dotnet-sdk#1802). +// Against an older sidecar GetPropagatedHistory() returns null and the sample +// exits gracefully. + +using Dapr.Client; +using Dapr.Workflow; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OrderProcessor; + +const string Banner = + "================================================================\n" + + "= WORKFLOW HISTORY PROPAGATION DEMO — PATIENT INTAKE (.NET) =\n" + + "================================================================"; + +// --------------------------------------------------------------------------- +// Host setup — register workflows and activities +// --------------------------------------------------------------------------- + +var builder = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddDaprClient(); + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + options.RegisterWorkflow(); + options.RegisterWorkflow(); + options.RegisterWorkflow(); + + options.RegisterActivity(); + options.RegisterActivity(); + options.RegisterActivity(); + options.RegisterActivity(); + }); + }); + +using var host = builder.Build(); +host.Start(); + +var workflowClient = host.Services.GetRequiredService(); + +// --------------------------------------------------------------------------- +// Kick off the root workflow +// --------------------------------------------------------------------------- + +Console.WriteLine(Banner); +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"); +Console.WriteLine(); + +var record = new PatientRecord( + PatientId: "P-1042", + Name: "Jane Doe", + Dob: "1985-06-12", + Mrn: "MRN-77231", + Condition: "bacterial sinusitis", + Medication: "amoxicillin", + Dosage: 500); + +const string InstanceId = "intake-001"; + +Console.WriteLine($" [main] Scheduling workflow instance: {InstanceId}"); + +await workflowClient.ScheduleNewWorkflowAsync( + name: nameof(PatientIntakeWorkflow), + instanceId: InstanceId, + input: record); + +var state = await workflowClient.WaitForWorkflowCompletionAsync( + instanceId: InstanceId, + cancellationToken: new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token); + +if (state is null) +{ + Console.WriteLine(" [main] Workflow not found!"); +} +else if (state.RuntimeStatus == WorkflowRuntimeStatus.Completed) +{ + Console.WriteLine($" [main] Workflow completed! Output: {state.SerializedOutput}"); +} +else +{ + Console.WriteLine($" [main] Workflow ended with status: {state.RuntimeStatus}"); +} + +Console.WriteLine(); +Console.WriteLine("================================================================"); +Console.WriteLine("= COMPLETE ="); +Console.WriteLine("================================================================"); diff --git a/workflows/csharp/sdk-context-propagation/order-processor/Workflows.cs b/workflows/csharp/sdk-context-propagation/order-processor/Workflows.cs new file mode 100644 index 000000000..db07aa015 --- /dev/null +++ b/workflows/csharp/sdk-context-propagation/order-processor/Workflows.cs @@ -0,0 +1,294 @@ +// ------------------------------------------------------------------------ +// 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 OrderProcessor; + +using Dapr.Workflow; + +// --------------------------------------------------------------------------- +// PatientIntake (root workflow) +// --------------------------------------------------------------------------- + +/// +/// Root workflow — verifies the patient's insurance then delegates the +/// prescription to a child workflow with full +/// propagation so the grandchild +/// ComplianceAudit can inspect the complete ancestor chain. +/// +public sealed class PatientIntakeWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext ctx, PatientRecord rec) + { + if (!ctx.IsReplaying) + Console.WriteLine($" [PatientIntake] Starting intake for patient {rec.PatientId}"); + + // Step 1: Verify insurance — no propagation (plain activity). + if (!ctx.IsReplaying) + Console.WriteLine(" [PatientIntake] Step 1: VerifyInsurance (no propagation)"); + var insured = await ctx.CallActivityAsync( + nameof(VerifyInsuranceActivity), + rec); + if (!insured) + return "intake declined: insurance not on file"; + if (!ctx.IsReplaying) + Console.WriteLine(" [PatientIntake] Step 1 complete: insurance verified"); + + // Step 2: Delegate to PrescribeMedication with Lineage propagation. + // PrescribeMedication inherits this workflow's full history so its own + // grandchild ComplianceAudit can verify the complete ancestor chain. + if (!ctx.IsReplaying) + Console.WriteLine(" [PatientIntake] Step 2: PrescribeMedication child wf (HistoryPropagationScope.Lineage)"); + var result = await ctx.CallChildWorkflowAsync( + nameof(PrescribeMedicationWorkflow), + rec, + new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.Lineage)); + + if (!ctx.IsReplaying) + Console.WriteLine($" [PatientIntake] COMPLETE: {result}"); + return result; + } +} + +// --------------------------------------------------------------------------- +// PrescribeMedication (child workflow, level 2) +// --------------------------------------------------------------------------- + +/// +/// Child workflow — orchestrates allergy + interaction screening, compliance +/// audit, and dispensing. Receives +/// from PatientIntake, so it holds the full ancestor chain when calling its +/// own children. Calls ComplianceAudit with Lineage (audit needs to see the +/// grandparent) and DispenseMedicationWorkflow with OwnHistory (pharmacy only +/// sees the prescribing step, not the intake). +/// +public sealed class PrescribeMedicationWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext ctx, PatientRecord rec) + { + if (!ctx.IsReplaying) + Console.WriteLine($" [PrescribeMedication] Starting prescription: {rec.Medication} {rec.Dosage:F0}mg for {rec.Condition}"); + + // Step 1: Allergy check (no propagation). + if (!ctx.IsReplaying) + Console.WriteLine(" [PrescribeMedication] Step 1: CheckAllergies (no propagation)"); + var allergyClear = await ctx.CallActivityAsync( + nameof(CheckAllergiesActivity), + rec); + if (!allergyClear) + return "prescription declined: known allergy"; + if (!ctx.IsReplaying) + Console.WriteLine(" [PrescribeMedication] Step 1 complete: allergy clear"); + + // Step 2: Drug interaction screen (no propagation). + if (!ctx.IsReplaying) + Console.WriteLine(" [PrescribeMedication] Step 2: ScreenDrugInteractions (no propagation)"); + var interactionsClear = await ctx.CallActivityAsync( + nameof(ScreenDrugInteractionsActivity), + rec); + if (!interactionsClear) + return "prescription declined: drug interaction risk"; + if (!ctx.IsReplaying) + Console.WriteLine(" [PrescribeMedication] Step 2 complete: no interactions"); + + // Step 3: Compliance audit grandchild workflow with Lineage propagation. + // ComplianceAudit will see both PatientIntake AND PrescribeMedication events. + if (!ctx.IsReplaying) + Console.WriteLine(" [PrescribeMedication] Step 3: ComplianceAudit child wf (HistoryPropagationScope.Lineage)"); + var audit = await ctx.CallChildWorkflowAsync( + nameof(ComplianceAuditWorkflow), + rec, + new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.Lineage)); + if (!audit.Compliant) + return $"prescription blocked: compliance audit failed (risk={audit.RiskScore:F2}, reason={audit.Reason})"; + if (!ctx.IsReplaying) + Console.WriteLine($" [PrescribeMedication] Step 3 complete: compliance audit passed (risk={audit.RiskScore:F2})"); + + // Step 4: Dispense the medication as a child workflow with OwnHistory propagation. + // DispenseMedicationWorkflow only sees PrescribeMedication's own events — not PatientIntake. + // Note: the .NET SDK propagation support is on ChildWorkflowTaskOptions only. + // For a trust-boundary demo equivalent to PropagationScope.OWN_HISTORY in Python/Go, + // DispenseMedication is implemented as a child workflow (not a bare activity). + if (!ctx.IsReplaying) + Console.WriteLine(" [PrescribeMedication] Step 4: DispenseMedicationWorkflow child wf (HistoryPropagationScope.OwnHistory)"); + var dispense = await ctx.CallChildWorkflowAsync( + nameof(DispenseMedicationWorkflow), + rec, + new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.OwnHistory)); + if (!ctx.IsReplaying) + Console.WriteLine($" [PrescribeMedication] Step 4 complete: dispensed (id={dispense.DispenseId})"); + + var summary = $"dispensed: id={dispense.DispenseId}, patient={rec.PatientId}, drug={rec.Medication} {rec.Dosage:F0}mg"; + if (!ctx.IsReplaying) + Console.WriteLine($" [PrescribeMedication] COMPLETE: {summary}"); + return summary; + } +} + +// --------------------------------------------------------------------------- +// ComplianceAudit (grandchild workflow, level 3) +// --------------------------------------------------------------------------- + +/// +/// Grandchild workflow that inspects the full ancestor chain to make a +/// trust-aware compliance decision. Receives +/// from PrescribeMedication, so +/// returns entries for both PatientIntake and PrescribeMedication. Refuses to +/// approve dispensing unless the required upstream steps (insurance, allergies, +/// drug interactions) are all present and completed in the propagated history. +/// +public sealed class ComplianceAuditWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext ctx, PatientRecord rec) + { + if (!ctx.IsReplaying) + Console.WriteLine($" [ComplianceAudit] Auditing prescription for patient {rec.PatientId}"); + + var history = ctx.GetPropagatedHistory(); + if (history is null) + { + if (!ctx.IsReplaying) + { + Console.WriteLine(" [ComplianceAudit] WARNING: no propagated history — sidecar may not support 1.18+"); + Console.WriteLine(" [ComplianceAudit] BLOCKED — cannot verify upstream pipeline without history"); + } + return Task.FromResult(new ComplianceResult( + Compliant: false, + RiskScore: 1.0, + Reason: "no execution history provided — cannot verify caller pipeline", + EventCount: 0)); + } + + if (!ctx.IsReplaying) + { + Console.WriteLine($" [ComplianceAudit] Received propagated history with {history.Entries.Count} segment(s):"); + foreach (var entry in history.Entries) + Console.WriteLine($" [ComplianceAudit] workflow: name={entry.WorkflowName} app={entry.AppId} events={entry.Events.Count}"); + } + + // Verify PatientIntake is present in the ancestor chain. + var intakeEntries = history.FilterByWorkflowName(nameof(PatientIntakeWorkflow)); + if (intakeEntries.Entries.Count == 0) + { + return Task.FromResult(new ComplianceResult( + Compliant: false, + RiskScore: 0.9, + Reason: $"{nameof(PatientIntakeWorkflow)} missing from propagated history", + EventCount: history.Entries.Count)); + } + + // Verify PrescribeMedication is present in the ancestor chain. + var prescribeEntries = history.FilterByWorkflowName(nameof(PrescribeMedicationWorkflow)); + if (prescribeEntries.Entries.Count == 0) + { + return Task.FromResult(new ComplianceResult( + Compliant: false, + RiskScore: 0.9, + Reason: $"{nameof(PrescribeMedicationWorkflow)} missing from propagated history", + EventCount: history.Entries.Count)); + } + + // Verify the required activity completions are recorded in history events. + var intakeEntry = intakeEntries.Entries[0]; + var prescribeEntry = prescribeEntries.Entries[0]; + + int intakeCompletedCount = intakeEntry.Events.Count(e => e.Kind == HistoryEventKind.TaskCompleted); + int prescribeCompletedCount = prescribeEntry.Events.Count(e => e.Kind == HistoryEventKind.TaskCompleted); + + if (!ctx.IsReplaying) + { + Console.WriteLine(" [ComplianceAudit] Verification:"); + Console.WriteLine($" PatientIntake TaskCompleted events: {intakeCompletedCount} (expect >= 1: VerifyInsurance)"); + Console.WriteLine($" PrescribeMedication TaskCompleted events: {prescribeCompletedCount} (expect >= 2: CheckAllergies, ScreenDrugInteractions)"); + } + + if (intakeCompletedCount == 0 || prescribeCompletedCount < 2) + { + if (!ctx.IsReplaying) + Console.WriteLine(" [ComplianceAudit] BLOCKED — required upstream checks not completed"); + return Task.FromResult(new ComplianceResult( + Compliant: false, + RiskScore: 0.9, + Reason: "required upstream checks not completed in propagated history", + EventCount: history.Entries.Count)); + } + + int totalEventCount = history.Entries.Sum(e => e.Events.Count); + double riskScore = rec.Dosage > 1000 ? 0.3 : 0.1; + if (!ctx.IsReplaying) + Console.WriteLine($" [ComplianceAudit] APPROVED (risk={riskScore:F2}, total events inspected={totalEventCount})"); + + return Task.FromResult(new ComplianceResult( + Compliant: true, + RiskScore: riskScore, + Reason: "all upstream checks verified in propagated history", + EventCount: totalEventCount)); + } +} + +// --------------------------------------------------------------------------- +// DispenseMedicationWorkflow (grandchild workflow, level 3) +// --------------------------------------------------------------------------- + +/// +/// Dispense workflow — receives +/// from PrescribeMedication, so it can only see PrescribeMedication's own events. +/// This demonstrates the trust-boundary mode: the PatientIntake ancestor history +/// is intentionally excluded — the pharmacy system doesn't need (or get to see) +/// the upstream patient-intake chain. +/// +/// +/// In the Python sibling and the Go reference this is implemented as a bare +/// activity because those SDKs support a propagation argument on activity calls. +/// The .NET SDK's is currently scoped to +/// child workflows only (), so we use a +/// child workflow here to demonstrate the identical OwnHistory boundary. +/// +public sealed class DispenseMedicationWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext ctx, PatientRecord rec) + { + var history = ctx.GetPropagatedHistory(); + + int eventCount = 0; + if (history is not null) + { + eventCount = history.Entries.Sum(e => e.Events.Count); + if (!ctx.IsReplaying) + { + Console.WriteLine($" [DispenseMedicationWorkflow] Propagated segments: {history.Entries.Count}"); + foreach (var entry in history.Entries) + { + Console.WriteLine($" [DispenseMedicationWorkflow] workflow: name={entry.WorkflowName} app={entry.AppId} events={entry.Events.Count}"); + foreach (var evt in entry.Events.Take(5)) + Console.WriteLine($" [DispenseMedicationWorkflow] event: kind={evt.Kind} id={evt.EventId}"); + if (entry.Events.Count > 5) + Console.WriteLine($" [DispenseMedicationWorkflow] ... ({entry.Events.Count - 5} more events)"); + } + + // With OwnHistory, PatientIntake should NOT appear here. + var intakeEntries = history.FilterByWorkflowName(nameof(PatientIntakeWorkflow)); + Console.WriteLine($" [DispenseMedicationWorkflow] PatientIntake in history (expected 0): {intakeEntries.Entries.Count}"); + } + } + else if (!ctx.IsReplaying) + { + Console.WriteLine(" [DispenseMedicationWorkflow] No propagated history received"); + } + + var result = await ctx.CallActivityAsync( + nameof(DispenseMedicationActivity), + rec); + + return result with { EventCount = eventCount }; + } +}