Gå til innhold

ADR-013: Workflow-orkestrering på regelmotoren (utvidelse av ADR-010)⚓︎

Utvider ADR-010 med workflow-graf-orkestrering og typede facts-pipelines. Regel-definisjon, lasting og evaluering eies fortsatt av ADR-010.

Revidert 2026-07-13 (AB#21271): status oppdatert fra proposed til accepted — arkitekturen beskrevet her er bygget, testdekket og i drift. Typenavn og fillenker er oppdatert etter det norske domenespråk-omdøpet (ADR-015): RuleRegel, RuleKindRegelType, RuleEvaluationResultRegelEvalueringsResultat, CaseEvaluationRegelresultat.

Kontekst og problemstilling⚓︎

ADR-010 ble akseptert 2026-05-21 med valget «MAF Workflows + YAML-deklarert regelmotor»: regler i YAML-filer som embedded resources, en funksjon-peker per regel til en C#-metode (IRegelFunksjon + FunksjonRegistry), IDataRetriever for datahenting, og MAF Workflows som beslutningstaker.

Ingen av disse komponentene finnes i kodebasen. Det som ble bygget — først i regelmotor-spiken 2026-04-27, deretter i v2-restruktureringen og US #20185 — er en annen arkitektur:

  • Regelmotor: Microsoft RulesEngine evaluerer strukturerte regler; en LLM evaluerer skjønnsregler. Regeldefinisjoner lagres som JSON i Postgres (Regel.DefinitionJson), ikke som YAML embedded resources.
  • Beslutningstaker: en egen, native WorkflowGraphRunner — ikke MAF Workflows.

ADR-010 er dermed faktuelt feil om systemet den beskriver. «Presiseringer ved akseptering»-blokken korrigerte beslutningstakeren til native runner, men resten av ADR-010 (regelmotor-mekanismen, implementasjonsplanen, IRegelFunksjon/FunksjonRegistry/IDataRetriever) beskriver fortsatt ikke-eksisterende kode. En agent eller utvikler som leser ADR-010 og stoler på den, vil bygge feil.

ADR-010s revisit-trigger («hvis antall virkemidler overstiger ~10 … vurder Microsoft RulesEngine på nytt») er teknisk utløst — det er 37 virkemidler — men poenget er irrelevant: RulesEngine var ikke et fremtidig alternativ å revurdere, det var allerede den implementerte løsningen da ADR-010 ble akseptert.

Denne ADR-en bringer beslutningsdokumentasjonen i samsvar med koden, og fastsetter de to forskjellene fra ADR-010 (regelmotor og beslutningstaker) som bevisste, besluttede valg.

Nøkkeldrivere⚓︎

  • Beslutningsdokumentasjon må stemme med koden — en ADR som beskriver ikke-eksisterende komponenter er verre enn ingen ADR.
  • Regler skal kunne opprettes og endres uten ny deploy. Regler som kompilerte C#-funksjoner (ADR-010s valg) krever rekompilering og deploy for hver regelendring og hver nye regel. Med 37 virkemidler × flere regler hver er det en uakseptabel driftsmodell.
  • Regelkjøringer skal være sporbare og deterministiske: saksbehandler skal kunne se hvilke regler som kjørte, mot hvilke fakta, med hvilket utfall og hvilken begrunnelse.
  • Regler skrives av utviklere (i samråd med fagansvarlige for faglige avklaringer) — ikke av fagansvarlige direkte. RulesEngines uttrykkssyntaks er akseptabel lesbarhet for utviklere.
  • Strukturerte sjekker (terskelverdier, datoer, sammenligninger) skal evalueres deterministisk; skjønnsvurderinger skal kunne uttrykkes som LLM-prompts.
  • Løsningen skal kjøre på .NET 10 og passe inn i v2-arkitekturen (Application/Domain/Infrastructure).
  • Eksterne rammeverk for orkestrering vurderes som leverandøravhengighet; MAF Workflows er fortsatt prerelease.

Vurderte alternativer⚓︎

ADR-010 vurderte fire alternativer for regelmotoren (C# pipeline, Microsoft RulesEngine, NRules, MAF Workflows + YAML) — den analysen gjentas ikke her. Etter at RulesEngine-hybriden og den native runneren allerede er bygget og testdekket, er det reelle valget nå avgrenset:

  1. Behold og fastsett det som er bygget — RulesEngine-hybrid + native WorkflowGraphRunner.
  2. Riv ut og bygg ADR-010s løsning — MAF Workflows + YAML-funksjoner. Forkaster fungerende, testdekket kode; tar inn en prerelease-avhengighet; samtale-/agent-orientert rammeverk som ikke passer en saks-evalueringspipeline.
  3. Behold regelmotoren, bytt beslutningstaker til MAF Declarative Workflows — gir checkpointing og løkker, men forkaster den native runneren og dens YAML-format, og krever fortsatt en prerelease-avhengighet.

Beslutning⚓︎

Valgt alternativ: alternativ 1 — fastsett RulesEngine-hybriden og den native workflow-grafen som besluttet arkitektur.

Regelmotor: hybrid RulesEngine + LLM Judgment⚓︎

Regel er et domene-aggregat (Regel.cs) persistert i Postgres. Hver regel har en Kind-diskriminator og en DefinitionJson:

  • RegelType.StructuredDefinitionJson er en RulesEngine Workflow (JSON). Evalueres deterministisk av RulesEngineEvaluator.
  • RegelType.JudgmentDefinitionJson er en JudgmentDefinition (Prompt, OutputType, RequiredFacts), der Prompt er hele prompt-teksten inline (AB#21258). Evalueres av en LLM via JudgmentEvaluator (IChatClient), med typet, strukturert utdata.

KindDispatchingRuleEvaluator implementerer IRegelEvaluator og dispatcher på Kind. Begge grener returnerer samme RegelEvalueringsResultat (Verdict = Met/NotMet/NotApplicable/Error, Reason, ReasonDetail, AiCallAuditRef).

Et strukturert regeluttrykk ser slik ut:

JSON
{
  "WorkflowName": "EnergiYrkesbyggR013",
  "Rules": [{
    "RuleName": "ConditionalRevisorRequirement",
    "SuccessEvent": "Met",
    "ErrorMessage": "Tilskudd > 1 000 000 NOK krever revisorrapport, men ingen ble funnet.",
    "RuleExpressionType": "LambdaExpression",
    "Expression": "facts.Tilskuddsbelop <= 1000000 || facts.Documents.Any(d => d.Kind == \"RevisorReport\")"
  }]
}

Beslutningstaker: native WorkflowGraphRunner⚓︎

WorkflowGraphRunner er ~140 linjer egen kode som eksekverer en graf av regel-noder. Kanttyper: Direct, Conditional, FanOut, FanIn. Grafen lagres som YAML i Postgres (WorkflowDefinitionRecord.YamlContent, deserialisert av WorkflowYamlDeserializer). HITL-pause skjer ved at en regel-node returnerer RuleResult.WaitingForHuman = true. Orkestreringslaget er 100 % deterministisk C#; LLM finnes kun inne i Judgment-regler.

Fakta-laget⚓︎

IFactsExtractorRegistry løser opp riktig Facts-type og ekstraktor per virkemiddel. Facts er per-virkemiddel C#-records (f.eks. EnergiYrkesbyggFacts) — ikke en felles katalog.

Konsekvenser⚓︎

  • Positivt: regler opprettes og endres som data i Postgres (Regel.DefinitionJson, ny Version, Status Draft→Active) — ingen deploy for nye eller endrede regler.
  • Positivt: strukturerte regler er deterministiske og enhetstestbare (RulesEngineEvaluatorTests).
  • Positivt: Regelresultat lagrer FactsSchemaVersion, FactsSnapshotHash (SHA-256) og EngineVersion — full sporbarhet for hver kjøring.
  • Positivt: orkestreringsgrafen er ren deterministisk C#; ikke-determinisme er innkapslet i Judgment-noder og isolert som egne RegelEvalueringsResultat.
  • Positivt: ingen ekstern rammeverksavhengighet for orkestrering — teamet eier hele runneren.
  • Negativt: RulesEngine-uttrykk er ikke statisk typesjekket — feil i et uttrykk oppdages først ved kjøring (Verdict.Error). Se «Kjente begrensninger».
  • Negativt: den native runneren har ikke durable resume eller løkker. Se «Kjente begrensninger».
  • Nøytralt: Microsoft.RulesEngine 6.0.0 er en ny avhengighet — men stabil og GA, i motsetning til MAF Workflows (prerelease).

Prompt-forvaltning: inline i regelen, ikke eget register (AB#21258, 2026-07-14)⚓︎

Judgment-prompten lå opprinnelig i et eget kode-register (InMemoryPromptTemplateRegistry), og JudgmentDefinition pekte på den via (PromptTemplateId, PromptTemplateVersion). To auditer anbefalte motsatt løsning: (a) inline prompten i regelen for én kilde, (b) behold registeret for gjenbruk på tvers av virkemidler. Besluttet: inline — prompt-teksten bor i prompt-feltet i regelens YAML og bæres uendret inn i JudgmentDefinition.Prompt (og dermed DefinitionJson).

Begrunnelse: (1) virkemiddelansvarlig ser hele regelen — inkludert prompten — på ett sted; (2) feilklassen «regel peker på ikke-eksisterende prompt» blir strukturelt umulig — det finnes ingen peker å bomme på, teksten er definisjonen (registeret gav bare en degradert Verdict.Error ved oppslagsfeil, ikke en fastslått garanti); (3) prompten versjoneres nå sammen med regelen (Regel.Version), ikke i et parallelt versjonsskjema. Kostnad: tre domene-nøytrale prompter (gyldighet/kontrakt/MVA) dupliseres på tvers av de to virkemidlene — men per-virkemiddel-eierskap er mer korrekt enn delt id-kobling, der en endring i det ene virkemiddelet ellers stille ville endret det andre. Historikk over tidligere prompt-tekst eies av den DB-authored versjoneringen (AB#21018) og append-only-historikken (AB#21260), ikke av et register.

Arkitektur (som bygget)⚓︎

flowchart TD
    A[EvaluateCaseCommand] --> R[IRegelRepository.GetApplicableAsync<br/>virkemiddel × ErsStatus × Mode]
    W[RunCaseWorkflowCommand] --> G[WorkflowGraphRunner<br/>graf av regel-noder]
    R --> F[IFactsExtractorRegistry.ExtractAsync]
    G --> F
    F --> E[IRegelEvaluator = KindDispatchingRuleEvaluator]
    E -- Structured --> RE[RulesEngineEvaluator<br/>RulesEngine 6.0.0]
    E -- Judgment --> JE[JudgmentEvaluator<br/>IChatClient]
    RE --> RES[RegelEvalueringsResultat]
    JE --> RES
    RES --> CE[Regelresultat<br/>persistert i Postgres]

Det finnes to orkestrerings-innganger som begge ender i samme IRegelEvaluator og Regelresultat:

Inngang Handler Orkestrering EngineVersion
EvaluateCaseCommand EvaluateCaseHandler Flat liste — alle anvendelige regler i rekkefølge RulesEngine/6.0.0
RunCaseWorkflowCommand RunCaseWorkflowHandler Graf via WorkflowGraphRunner WorkflowGraphRunner/1.0.0

RunCaseWorkflowJob (TickerQ, ADR-012) er den durable inngangen og kaller graf-stien.

Berørte filer⚓︎

  • src/Enova.Kai.Domain/Rules/Regel, RegelType, RegelStatus, Verdict, RegelEvalueringsResultat, Regelresultat
  • src/Enova.Kai.Application/Rules/IRegelEvaluator, IFactsExtractorRegistry, JudgmentDefinition, EvaluateCaseHandler, ActivateRule*, Facts-records
  • src/Enova.Kai.Infrastructure/Rules/Engine/RulesEngineEvaluator, JudgmentEvaluator, KindDispatchingRuleEvaluator
  • src/Enova.Kai.Application/WorkflowEngine/WorkflowGraphRunner, EdgeType, WorkflowGraphConfiguration, RunCaseWorkflowHandler
  • src/Enova.Kai.Infrastructure/Persistence/RuleRepository, RuleConfiguration, WorkflowDefinitionRecord, WorkflowConfigurationSeeder, Yaml/WorkflowYamlDeserializer
  • src/Enova.Kai.Infrastructure/Enova.Kai.Infrastructure.csprojPackageReference RulesEngine

Kjente begrensninger⚓︎

Disse er reelle hull i den bygde løsningen. De er ikke blokkerende for første reelle bruk, men de må følges opp og er listet her slik at ingen tror de er løst.

  1. Regeluttrykk valideres ikke ved aktivering. ActivateRuleValidator sjekker kun RuleId og ActivatedBy. Et RulesEngine-uttrykk som refererer et felt Facts-typen ikke har, gir Verdict.Error først ved kjøring. → Oppfølging: tørrkjør uttrykket mot Facts-skjemaet i ActivateRule-stien.
  2. HITL-persistens er ufullstendig. Regelresultat har ikke Status eller PausedAtRuleId. WorkflowGraphRunner kan pause på WaitingForHuman, men en pauset kjøring lagres ikke som gjenopptakbar tilstand. → Oppfølging: ADR-010s presisering om Regelresultat.Status + resume-endepunkt (Task #20423).
  3. Ingen durable resume. WorkflowGraphRunner.ExecuteAsync starter alltid på __start__ og tar ingen «gjenoppta fra node»-parameter. Etter en HITL-pause vil en gjenopptakelse re-eksekvere hele grafen — inkludert Judgment-noder, som da gir ny LLM-kostnad og ny ikke-determinisme. → Oppfølging: persistér RuleContext + pausepunkt, og la runneren hoppe forbi noder som allerede har resultat.
  4. Ingen løkke-kant. Runneren støtter ikke løkker (f.eks. «be om manglende fakta → re-ekstraher»). Manglende fakta håndteres i stedet som en gate-node (FactsAvailabilityExecutor).
  5. Workflow-grafer er tomme. Den seedede grafen for Tunge nullutslipp har nodes: [] / edges: []; graf-stien short-circuiter. I praksis kjører evaluering i dag som flat regelliste via EvaluateCaseHandler. Grafene må fylles inn per virkemiddel.
  6. Produksjons-wiring ufullstendig. RuleEngineStubs registrerer NotImplementedChatClient og PendingFactsExtractorRegistry i Postgres-modus — ekte IChatClient (Azure AI Foundry) og Facts-ekstraktor er ikke wiret ennå.
  7. Judgment: strukturert utdata ikke håndhevet. ChatResponseFormat.ForJsonSchema er ikke satt; malformert LLM-svar degraderer til Verdict.Error. RenderPrompt fletter søker-kontrollert dokumenttekst inn i prompten — kjent prompt-injection-vektor (R026).

Verifisering⚓︎

  • [x] RulesEngineEvaluator evaluerer Structured-regler fra Regel.DefinitionJson (RulesEngineEvaluatorTests passerer).
  • [x] JudgmentEvaluator evaluerer Judgment-regler via IChatClient med typet utdata.
  • [x] KindDispatchingRuleEvaluator dispatcher korrekt på RegelType.
  • [x] WorkflowGraphRunner eksekverer Direct/Conditional/FanOut/FanIn og pauser på WaitingForHuman (WorkflowGraphRunnerTests).
  • [x] Regelresultat lagrer FactsSchemaVersion, FactsSnapshotHash og EngineVersion.
  • [ ] Regeluttrykk valideres ved aktivering (begrensning 1).
  • [ ] Pauset Regelresultat lagres som gjenopptakbar tilstand med Status/PausedAtRuleId (begrensning 2–3).
  • [ ] Workflow-graf fylt inn for minst ett virkemiddel (begrensning 5).
  • [ ] Ekte IChatClient og Facts-ekstraktor wiret i Postgres-modus (begrensning 6).

Revisit-triggere⚓︎

  • Hvis durable resume over lange HITL-pauser blir et krav, og re-kjøring av Judgment-noder ved gjenopptakelse blir et reelt kostnads- eller determinismeproblem: implementer checkpoint-skip i WorkflowGraphRunner, eller revurder MAF Declarative Workflows når den er GA.
  • Hvis workflow-grafer trenger løkker: legg til en løkke-kanttype, eller revurder MAF.
  • Hvis regler skal forfattes av fagansvarlige direkte (ikke utviklere): RulesEngines uttrykkssyntaks er ikke egnet — revurder et mer fagvennlig regelformat.

Tilleggsinformasjon⚓︎

  • Erstatter ADR-010. ADR-010 settes til superseded.
  • Relaterte beslutninger: ADR-011 (Postgres for fakta og regelresultater), ADR-012 (TickerQ — durable inngang for graf-stien), ADR-006 (købasert analyse).
  • Regelmotor-spiken som etablerte hybriden: docs/superpowers/plans/2026-04-27-rules-engine-spike.md.
  • Regelkilde og versjonering (hvordan SharePoint-regler speiles til v2) er fortsatt utsatt til egen ADR — uendret fra ADR-010.