Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
db3d947
chore(tests): switch Ollama model to qwen2.5:1.5b, enforce serial E2E…
JerrettDavis Apr 29, 2026
4c087fc
feat(dashboard): surface AI DSL Emitter demo as featured template and…
JerrettDavis Apr 29, 2026
986b7c8
fix: remove stale {provider} placeholder from seeded AI DSL Emitter w…
JerrettDavis Apr 29, 2026
4ab401f
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
e309b13
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
3b36ae6
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
ebabfaf
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
6525fc1
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
3de2aae
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
13b2047
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
79fbeaf
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
2499164
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
3a174cf
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 22, 2026
010a179
Merge branch 'main' of https://github.com/JerrettDavis/WorkflowFramework
JerrettDavis May 23, 2026
e734307
chore(deps): bump PatternKit.Core to 0.112.0
JerrettDavis May 23, 2026
52400ef
refactor(integration-channel): use PatternKit AsyncWireTap (deletes ~…
JerrettDavis May 23, 2026
561901f
docs(patternkit): update adoption inventory and document 0.112.0 defe…
JerrettDavis May 23, 2026
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
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
<PackageVersion Include="Reqnroll.xUnit" Version="3.0.0" />

<!-- PatternKit -->
<PackageVersion Include="PatternKit.Core" Version="0.105.0" />
<PackageVersion Include="PatternKit.Core" Version="0.112.0" />

<!-- TinyBDD -->
<PackageVersion Include="TinyBDD" Version="0.19.16" />
Expand Down
39 changes: 27 additions & 12 deletions docs/patternkit-adoption.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# PatternKit Adoption Inventory

**PatternKit version:** 0.105.0
**Last updated:** 2026-05-22 (Phase I coverage tightening)
**PatternKit version:** 0.112.0
**Last updated:** 2026-05-22 (feat/consume-patternkit-0.112 — WireTapStep adopted)

This document lists every point in the WorkflowFramework codebase where a PatternKit primitive is used, and every point where a step is intentionally kept bespoke with the rationale for that decision. This is the canonical reference for Phase I and future phases.

Expand Down Expand Up @@ -44,6 +44,19 @@ This document lists every point in the WorkflowFramework codebase where a Patter
| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Routing/ContentBasedRouterStepScenarios.cs` |
| **Public API change** | None — swap is internal-only. |

### 4. `WireTapStep` — Wire tap channel pattern

| Item | Detail |
|------|--------|
| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs` |
| **PatternKit namespace** | `PatternKit.Messaging.Channels` |
| **Primitive** | `AsyncWireTap<IWorkflowContext>` |
| **Purpose** | Wraps the caller-supplied `Func<IWorkflowContext, Task>` into a PatternKit tap handler. The `swallowErrors` constructor parameter maps to `TapErrorPolicy.Swallow` / `TapErrorPolicy.Propagate`. The `IWorkflowContext` is wrapped in a `Message<IWorkflowContext>` for transit and unwrapped inside the tap lambda — no public API or behavioral change. |
| **Phase introduced** | feat/consume-patternkit-0.112 |
| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs` — all 8 Phase G.3 scenarios pass without modification. |
| **Public API change** | None — swap is internal-only. |
| **Net delta** | −15 lines of bespoke logic (try/catch error-swallowing replaced by `TapErrorPolicy`). |

---

## Intentionally Bespoke
Expand Down Expand Up @@ -146,13 +159,9 @@ The following EIP steps and other components were evaluated against PatternKit 0
| **Rationale** | PatternKit `AsyncAdapter<TIn,TOut>` is a type-mapping pattern (produce `TOut` from `TIn`). `ChannelAdapterStep` is a side-effect operation (send/receive via `IChannelAdapter`). The send/receive contract doesn't fit the adapt-a-type signature. |
| **Test coverage** | Phase G.3 characterization tests |

#### `WireTapStep`
#### ~~`WireTapStep`~~ — **Adopted in feat/consume-patternkit-0.112**

| Item | Detail |
|------|--------|
| **File** | `src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs` |
| **Rationale** | Core contract (run a side-effect without disrupting the main flow, with optional error swallowing) is simpler than PatternKit's `AsyncActionDecorator` pipeline. `AsyncActionDecorator` wraps a component and transforms/intercepts results; `WireTapStep` purely fires-and-forgets a side channel. |
| **Test coverage** | Phase G.3 characterization tests |
Moved to the **Adopted** section above. Now delegates to `PatternKit.Messaging.Channels.AsyncWireTap<IWorkflowContext>`.

#### `MessageBridgeStep`

Expand Down Expand Up @@ -186,12 +195,18 @@ When evaluating a bespoke component for PatternKit adoption, the following crite

## Future Evaluation Targets

The following components are candidates for PatternKit adoption in later phases if suitable primitives become available:
The following components are candidates for PatternKit adoption in later phases if suitable primitives become available or interface alignment is achieved:

| Component | Potential Primitive | Blocking Reason Today |
| Component | Potential Primitive | Blocking Reason (assessed against 0.112.0) |
|-----------|--------------------|-----------------------|
| `AggregatorStep` | PatternKit Aggregator (future) | No primitive in 0.105.0 |
| `ScatterGatherStep` | PatternKit ScatterGather (future) | No primitive in 0.105.0 |
| `NormalizerStep` | `Normalizer<TRaw,TCanonical>` (now in 0.112.0) | Behavioral mismatch: PatternKit uses content predicates (first match wins); bespoke uses O(1) dictionary keyed dispatch. Error message format differs — test pins format name in exception text. See `docs/patternkit-followup.md`. |
| `ContentEnricherStep` | `AsyncContentEnricher<TPayload>` (now in 0.112.0) | Semantic mismatch: PatternKit returns an enriched payload copy; bespoke mutates `IWorkflowContext` in place via a `Func<IWorkflowContext, Task>`. Wrapping adds indirection for zero functional benefit. |
| `IdempotentReceiverStep` | `IdempotentReceiver<TPayload,TResult>` (now in 0.112.0) | Behavioral breaking change: PatternKit marks failed attempts as `Failed` in the store (allowing retry); bespoke registers the ID in a `HashSet` before calling inner, so a failed first attempt DOES suppress the second. Test `ReAttemptAfterExceptionIsSkipped` pins this behavior. |
| `ClaimCheckStep` / `ClaimRetrieveStep` | `ClaimCheck<TPayload>` (now in 0.112.0) | Interface mismatch: bespoke `IClaimCheckStore` is untyped (`object`); PatternKit `IClaimCheckStore<TPayload>` is typed. Bridging requires an adapter class, adding indirection without deleting complexity. |
| `PollingConsumerStep` | `AsyncPollingConsumer<TPayload>` (now in 0.112.0) | Semantic mismatch: PatternKit is a continuous polling loop (run until cancelled); bespoke is a single-shot poll (`PollAsync` → store results → return). Incompatible lifecycle models. |
| `ScatterGatherStep` | `AsyncScatterGather<TRequest,TResponse,TResult>` (now in 0.112.0) | Integration complexity: handlers mutate a shared `IWorkflowContext` and write results to named context keys; PatternKit's per-recipient isolation model returns typed `TResponse` values. Adapting while preserving the `__Result_{handler.Name}` and `ResultsKey` contract would re-implement the existing complexity via a wrapper, defeating the purpose. |
| `TransactionalOutboxStep` | `IOutboxStore<TPayload>` (now in 0.112.0) | Interface mismatch: bespoke `IOutboxStore` uses `SaveAsync(object) → string`; PatternKit `IOutboxStore<TPayload>` uses `EnqueueAsync(Message<TPayload>) → OutboxMessage<TPayload>`. Different return types and message wrapper model. |
| `AggregatorStep` | PatternKit Aggregator (future) | No Aggregator primitive in 0.112.0 |
| `PluginManager` | `Strategy` + `AbstractFactory` | Phase H.8 — not yet started |
| `AgentLoopStep` / `AgentDecisionStep` | TypeDispatcher | Phase H.7 — not yet started |
| `ResilienceMiddleware` (Polly) | `RetryPolicy` | Phase F pilot option B — deferred |
243 changes: 243 additions & 0 deletions docs/patternkit-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# PatternKit Follow-Up — 0.112.0 Adoption Deferrals

**Branch:** `feat/consume-patternkit-0.112`
**Date assessed:** 2026-05-22
**Assessor:** Claude Sonnet 4.6 (refactor agent)

This document captures every step evaluated against PatternKit 0.112.0 during
`feat/consume-patternkit-0.112` that was deferred with the specific reason why. It
supersedes the equivalent "Future Evaluation Targets" notes in `docs/patternkit-adoption.md`.
Use this as the starting point for the next PatternKit adoption pass.

---

## Deferred: NormalizerStep → Normalizer<TRaw,TCanonical>

**File:** `src/WorkflowFramework.Extensions.Integration/Transformation/NormalizerStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Transformation.Normalizer<TRaw,TCanonical>`

**Why deferred:**

PatternKit's `Normalizer<TRaw,TCanonical>` dispatches via *content predicates* evaluated in
registration order (first-match-wins). `NormalizerStep` dispatches via an O(1) `Dictionary<string, IStep>`
keyed lookup — the format detector returns a string key, not a `bool` predicate.

Two specific blockers:

1. **Predicate vs key dispatch model.** Converting each dictionary entry to a predicate
(`raw => format == key`) would change the dispatch from O(1) to O(n) and lose the dictionary
key contract from the constructor signature.

2. **Error message behavioral mismatch.** When no format matches, the bespoke step throws
`InvalidOperationException($"No translator found for format '{format}' and no default translator configured.")`.
The test `UnknownFormatNoDefaultThrows` asserts that the message contains the format string.
PatternKit's miss reason is `"No format handler matched the raw input for normalizer '{name}'."` —
the unknown format key is absent. Fixing this without modifying the test requires a custom
exception mapping wrapper that adds complexity rather than removing it.

**Resumption condition:** If PatternKit adds a keyed-dispatch variant of `Normalizer` that maps
string discriminators to handlers (similar to the `TypeDispatchRouter` sketch in `.plan/patternkit-extension-backlog.md`),
evaluate again.

---

## Deferred: ContentEnricherStep → AsyncContentEnricher<TPayload>

**File:** `src/WorkflowFramework.Extensions.Integration/Transformation/ContentEnricherStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Transformation.AsyncContentEnricher<IWorkflowContext>`

**Why deferred:**

`AsyncContentEnricher<TPayload>` is designed for functional enrichment: each step receives the
current payload, augments it, and returns a new payload. The step's implementation stores the
final enriched payload back in the `Message<TPayload>`.

`ContentEnricherStep` is a thin named wrapper around `Func<IWorkflowContext, Task>` — a
side-effecting context mutation. The `IWorkflowContext` is passed by reference; the delegate
mutates it in place. Adapting to PatternKit would require:

```csharp
AsyncContentEnricher<IWorkflowContext>.Create()
.Enrich("enrich", async (ctx, _, ct) => { await enrichAction(ctx); return ctx; })
.Build();
```

The `return ctx` discards PatternKit's copy-returning semantics entirely. The wrapper buys
nothing — the code after the refactor is longer than the original. The step itself is 3
non-trivial lines; PatternKit would add ~10 lines of builder setup.

**Resumption condition:** Not warranted at this complexity level. If PatternKit adds a
side-effect-oriented enricher (`AsyncSideEffectEnricher<T>` that does not require a return value),
revisit. Otherwise keep bespoke.

---

## Deferred: IdempotentReceiverStep → IdempotentReceiver<TPayload,TResult>

**File:** `src/WorkflowFramework.Extensions.Integration/Endpoint/IdempotentReceiverStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Reliability.IdempotentReceiver<IWorkflowContext,object>`

**Why deferred — behavioral breaking change:**

The bespoke step registers the message ID in the `HashSet<string>` *before* calling the inner
step. This means: if the inner step throws, a subsequent call with the same ID is *silently
skipped* (because the ID is already registered). This is tested explicitly:

```
Test: ReAttemptAfterExceptionIsSkipped
Pins: "ID was added to the set BEFORE calling inner, so a second call IS skipped
even if inner threw."
```

PatternKit's `IdempotentReceiver<TPayload,TResult>` has the opposite semantics:

1. `TryClaimAsync` claims the key (status = `Processing`)
2. Handler is called
3. On exception → `MarkFailedAsync` sets status to `Failed`

A subsequent `TryClaimAsync` with the same key in `Failed` status returns `Claimed = false` AND
sets the status back to `Processing`, allowing the handler to be retried. This is the correct
resilient behavior for production idempotency, but it breaks the characterization test.

**To unblock:** The test would need to be updated to remove the `ReAttemptAfterExceptionIsSkipped`
scenario (or relax it to document PatternKit's retry-on-failure semantics). This requires a
deliberate API-evolution decision — it is not safe to change as a refactor.

---

## Deferred: ClaimCheckStep / ClaimRetrieveStep → ClaimCheck<TPayload>

**File:** `src/WorkflowFramework.Extensions.Integration/Transformation/ClaimCheckStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Transformation.ClaimCheck<TPayload>`

**Why deferred — interface mismatch:**

The bespoke `IClaimCheckStore` (in `WorkflowFramework.Extensions.Integration.Abstractions`) is
untyped:

```csharp
Task<string> StoreAsync(object payload, CancellationToken ct);
Task<object> RetrieveAsync(string ticket, CancellationToken ct);
```

PatternKit's `IClaimCheckStore<TPayload>` is typed:

```csharp
ValueTask StoreAsync(string claimId, TPayload payload, MessageHeaders headers, CancellationToken ct);
ValueTask<ClaimCheckStoredPayload<TPayload>?> TryLoadAsync(string claimId, CancellationToken ct);
```

The differences are:
- Untyped `object` vs typed `TPayload`
- PatternKit requires `MessageHeaders` (not available in WF context model)
- PatternKit takes a `claimId` parameter; bespoke generates the ID internally
- PatternKit returns `ClaimCheckStoredPayload<TPayload>?` vs bespoke returning `object`
- PatternKit uses `ValueTask` vs bespoke `Task`

Bridging these interfaces would require an adapter class that wraps `IClaimCheckStore` in
`IClaimCheckStore<object>` (with dummy headers), adding ~20 lines of bridge code to save 0
lines in the step itself. Net: code increase.

**Resumption condition:** If `WorkflowFramework.Extensions.Integration.Abstractions` migrates to
PatternKit's `IClaimCheckStore<TPayload>` as its primary claim check interface, the step can
adopt directly.

---

## Deferred: PollingConsumerStep → AsyncPollingConsumer<TPayload>

**File:** `src/WorkflowFramework.Extensions.Integration/Endpoint/PollingConsumerStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Consumers.AsyncPollingConsumer<TPayload>`

**Why deferred — semantic lifecycle mismatch:**

`PollingConsumerStep<T>` is a *single-shot* poll step:
1. Call `IPollingSource<T>.PollAsync()` once
2. Write results to `context.Properties[ResultKey]`
3. Return

`AsyncPollingConsumer<TPayload>` is a *continuous polling loop*:
1. Runs a `while (!ct.IsCancellationRequested)` loop
2. Calls the poll source on each iteration
3. Invokes a handler per-message
4. Sleeps between polls with configurable jitter/backoff

These are different abstractions. The step is designed to be called from within a workflow
execution engine (once per workflow tick). PatternKit's consumer is designed to be run as a
background service (long-lived, driven to completion by cancellation).

**Resumption condition:** If a future PatternKit release adds a `SinglePollConsumer<T>` (one-shot
poll adaptor without the loop), that would fit. Alternatively, if WorkflowFramework adds a
background polling host that drives steps in a polling loop, `AsyncPollingConsumer` would be
the right fit there (not in the step itself).

---

## Deferred: ScatterGatherStep → AsyncScatterGather<TRequest,TResponse,TResult>

**File:** `src/WorkflowFramework.Extensions.Integration/Composition/ScatterGatherStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Routing.AsyncScatterGather<IWorkflowContext,object?,IReadOnlyList<object?>>`

**Why deferred — integration complexity and shared-context mutation model:**

The bespoke `ScatterGatherStep` relies on a pattern where:
- All handlers share the same `IWorkflowContext` reference
- Each handler writes its result to `context.Properties[$"__Result_{handler.Name}"]`
- After `Task.WhenAll`, the step reads each handler's named result key from context

PatternKit's `AsyncScatterGather` expects *isolated* per-recipient handlers that return a typed
`TResponse` value. Adapting would require:

1. Each recipient executes the handler (mutating shared context) then reads back its named key
2. The shared `IWorkflowContext` becomes a concurrency hazard when written by parallel recipients
(the existing implementation has the same hazard but the keys are named distinctly per handler)
3. The aggregator (`Func<IReadOnlyList<object?>, IWorkflowContext, Task>`) has a different
signature from PatternKit's `ResponseAggregator(IReadOnlyList<ResponseEnvelope<TResponse>>, Message<TRequest>, MessageContext)`

The adaptation would re-implement the shared-context fan-out logic inside PatternKit's
recipient framework without simplifying the code. The characterization tests in
`ScatterGatherStepTests.cs` pin the exact `__Result_{handler.Name}` key convention and the
`context.Properties[ResultsKey]` output.

**Resumption condition:** If the scatter-gather handlers are refactored to return typed values
(rather than writing to named context keys), the step could adopt `AsyncScatterGather` directly.
This is an API-evolution decision beyond the scope of a refactor.

---

## Deferred: TransactionalOutboxStep → IOutboxStore<TPayload>

**File:** `src/WorkflowFramework.Extensions.Integration/Endpoint/TransactionalOutboxStep.cs`

**PatternKit primitive:** `PatternKit.Messaging.Reliability.IOutboxStore<TPayload>`

**Why deferred — interface mismatch:**

Bespoke `IOutboxStore` (in Abstractions):

```csharp
Task<string> SaveAsync(object message, CancellationToken ct);
```

PatternKit `IOutboxStore<TPayload>`:

```csharp
ValueTask<OutboxMessage<TPayload>> EnqueueAsync(Message<TPayload> message, string? id, DateTimeOffset? createdAt, CancellationToken ct);
```

Key differences:
- Bespoke: untyped `object`, returns outbox ID as `string`
- PatternKit: typed `TPayload` wrapped in `Message<TPayload>`, returns full `OutboxMessage<TPayload>`
- Bespoke: ID is generated by the store; PatternKit accepts an optional caller-provided ID
- The step writes `OutboxIdKey = id` to context; PatternKit's return value is `OutboxMessage<TPayload>`,
requiring `.Id` access — minor but meaningful difference in null-safety and API surface

**Resumption condition:** If `WorkflowFramework.Extensions.Integration.Abstractions` migrates to
PatternKit's `IOutboxStore<TPayload>` as the primary interface, the step can adopt directly
(write `context.Properties[OutboxIdKey] = result.Id`).
Loading
Loading