Phase 1h — Combinable companion composition roots + guard/action DI
Phase 1h — Combinable companion composition roots + guard/action DI
This migration ships three consumer-visible changes:
WorkflowGuard/WorkflowActionsignature change. Both previously tookSubmission * AccessContext; both now take aWorkflowContextrecord carrying the submission, access context, AND the resolvedIServiceProvider. Actions can resolveIEntityStore,INotificationChannel, etc. fromctx.Servicesper invocation — no more request-side workarounds.FormsServerApp/AIServerAppinternal refactor onto compose transformers.FormsServerApp.run/AIServerApp.runkeep working unchanged; internally each is nowcomposeX >> ServerApp.run, wherecomposeForms/composeAIapply every companion contribution onto the innerServerAppand return the composed result. The old positionalcomposeWithAIis gone — anything wanting the seam directly usescomposeAI : AIServerApp -> ServerApp.- Additive companion-set extensions
withForms/withAI. Stack Forms and AI contributions onto one composition root without committing to eitherFormsServerApp.runorAIServerApp.runas the terminal call. Each takes a configurator function over a freshXServerAppwhoseBaseis the inputServerApp, runs the relevantcomposeXto merge contributions, and returns aServerAppready forServerApp.run(or anotherwithX).
Phase 1h's remaining tasks (the analogous withRAG additive
extension, the compose-time conflict validator, the combined-companion
sample refresh) ship in a follow-up. withRAG is gated on a
prior refactor that lifts composeWithRAG onto composeAI instead of
duplicating AI's DI registrations inline (see the existing comment in
RAGCompose.fs near the serviceConfig block) — landing withRAG
before that refactor would force the duplication into the additive
surface.
What changes
1. WorkflowGuard / WorkflowAction shape
Before:
type WorkflowGuard = Submission * AccessContext -> Async<Result<unit, string>>
type WorkflowAction = Submission * AccessContext -> Async<unit>
After:
type WorkflowContext = {
Submission: Submission
AccessContext: AccessContext
Services: System.IServiceProvider
}
type WorkflowGuard = WorkflowContext -> Async<Result<unit, string>>
type WorkflowAction = WorkflowContext -> Async<unit>
The engine resolves Services from the compose-time DI container and
threads it into the context per Apply invocation. Guards / actions
that don't need DI ignore the field; those that need
IEntityStore / INotificationChannel / IAuditLog / etc. resolve
them directly:
let stampJobEntity: WorkflowAction =
fun ctx -> async {
let store = ctx.Services.GetService(typeof<IEntityStore>) :?> IEntityStore
let! _ = store.Save("scope", { Id = ctx.Submission.Id; ... })
return ()
}
2. WorkflowEngine constructor
The engine constructor now takes an IServiceProvider as a final
parameter; FormsCompose.fs already threads the DI factory's sp
into the constructor, so deployments using FormsServerApp.run pick
this up transparently. Direct constructions (typically tests) need to
pass a provider — an empty
{ interface IServiceProvider with member _.GetService(_) = null }
shim is sufficient when guards/actions don't resolve services.
3. composeForms / composeAI internal seams
ToolUp.Forms.FormsCompose.composeForms : FormsServerApp -> ServerApp
and ToolUp.AI.AICompose.composeAI : AIServerApp -> ServerApp return
the composed ServerApp without driving it. Both marked
[<EditorBrowsable(Never)>] — consumers continue using
FormsServerApp.run / AIServerApp.run.
The old positional composeWithAI (also [<EditorBrowsable(Never)>])
is removed; nothing inside the SDK or any in-tree sample called it
externally, and the new composeAI is the only seam needed for the
Phase 1h additive extensions.
4. withForms / withAI additive extensions
Two new module-level functions sit alongside the existing
*ServerApp.run roots:
// ToolUp.Forms.FormsCompose
val withForms : (FormsServerApp -> FormsServerApp) -> ServerApp -> ServerApp
// ToolUp.AI.AICompose
val withAI :
IAIProviderFactory ->
IProviderProfile ->
(AIServerApp -> AIServerApp) ->
ServerApp ->
ServerApp
The configurator receives a fresh XServerApp whose Base is the
input ServerApp; it should call only the companion's own helpers
(FormsServerApp.withFormSchema / AIServerApp.withAIConfig / …),
not the delegating withConfig / withAuth / … helpers (which would
overwrite the outer pipeline's existing config — set base config on
the outer pipeline before calling withX).
Composes left-to-right; the canonical Forms + AI pipeline becomes:
ServerApp.empty
|> ServerApp.withConfig config
|> ServerApp.withAuth auth
|> ServerApp.withStorage storage
|> FormsCompose.withForms (fun f ->
f
|> FormsServerApp.withFormSchema mySchema
|> FormsServerApp.withWorkflow myWorkflow
|> FormsServerApp.withAction "stampJob" stampJobAction)
|> AICompose.withAI factory providerProfile (fun ai ->
ai
|> AIServerApp.withAIConfig assistant
|> AIServerApp.withModuleAIContexts contexts)
|> ServerApp.run
A workflow Action registered on the Forms half resolves
IEntityStore (or any other DI service) from ctx.Services — the
same provider seen by every other handler in the app, including the
AI agent loop. The pre-Phase-1h "actions could not reach DI" gap
disappears for combined deployments.
Calling withAI (or withForms) twice on the same pipeline composes
the companion twice: AI's metric registrations would re-append and
fail at sink construction (clear "duplicate metric name" error);
Forms's entity registrations would re-register and compose would
fail with a duplicate-name error. The Phase 1h conflict validator
(landing in a follow-up) gives a clearer diagnostic before either
failure mode surfaces. Until it lands, the existing duplicate-
detection paths still catch the misuse, just less helpfully.
Diff to apply (out-of-tree consumers)
Workflow guards (per-guard)
-let approveGuard: WorkflowGuard =
- fun (submission, accessContext) -> async {
- if accessContext.UserId = submission.Author then
- return Ok ()
- else
- return Error "Only the submitter can approve."
- }
+let approveGuard: WorkflowGuard =
+ fun ctx -> async {
+ if ctx.AccessContext.UserId = ctx.Submission.Author then
+ return Ok ()
+ else
+ return Error "Only the submitter can approve."
+ }
Workflow actions (per-action)
-let notifySubmitter: WorkflowAction =
- fun (submission, accessContext) -> async {
- // request-side workaround — actions could not reach DI
- do! sendNotificationOutOfBand submission accessContext
- }
+let notifySubmitter: WorkflowAction =
+ fun ctx -> async {
+ let channel =
+ ctx.Services.GetService(typeof<INotificationChannel>) :?> INotificationChannel
+ do! channel.Publish(ctx.Submission.Author, payloadFor ctx)
+ }
Actions that don't need DI just ignore ctx.Services.
Direct WorkflowEngine constructions (typically test code only)
let engine =
WorkflowEngine(
formStore,
auditLog,
ledger,
metricsSink,
warn,
workflows,
guards,
actions,
- actionPolicies
+ actionPolicies,
+ services
)
Tests can construct a null-returning provider:
let services: IServiceProvider =
{ new IServiceProvider with
member _.GetService(_) = null }
Production code using FormsServerApp.run requires no change —
the DI factory threads the provider in automatically.
Verification steps
dotnet buildthe consumer's solution — the guard/action signature is checked at compile time. Lambda call sites that still destructure(submission, accessContext)will fail to type-check with a clear "expectedWorkflowContext" message.- Workflow guard / action smoke test. Apply a transition through
IWorkflowEngine.Applyagainst a registered workflow whose guard reads from the newctx.Submissionfield and whose action resolves a DI service. Confirm:- Guard return-
Okproceeds to action; return-Error reasonsurfacesFormError.TransitionDenied. - Action
ctx.Services.GetService(typeof<IEntityStore>)returns the deployment's registered store (non-null in any non-test deployment).
- Guard return-
- Existing single-superset deployments —
FormsServerApp.run/AIServerApp.run/RAGServerApp.runstill compile and boot without change.
Rollback
The signature change is mechanical; revert involves:
- Restoring the tuple-shape
type WorkflowGuard = Submission * AccessContext -> _and the matchingWorkflowAction. - Removing the
services: IServiceProviderconstructor parameter fromWorkflowEngine. - Removing the
WorkflowContexttype. - Reverting consumer guards / actions back to the tuple destructuring shape.
The composeForms seam is internal; its removal is purely a
collapse of composeForms >> ServerApp.run back into one run body.