Gå til innhold

ADR-021: Vedlikeholdte projeksjonskolonner på Sak (behandlingsform, regelstatus, gjennomgått)⚓︎

Behandlingsform, SamletRegelstatus og Gjennomgangsstatus blir persisterte kolonner på Sak, vedlikeholdt av eksplisitte domenehendelser — RegelresultatOpprettet, BeslutningRegistrert, StadiegjennomgangEndret, SakStatusEndret — dispatchet post-commit av den allerede eksisterende DomainEventDispatchingInterceptor. Ingen ny infrastruktur-interceptor: hendelsene raises i aggregatenes egne metoder og håndteres av navngitte IDomainEventHandler<T>. Dispatch er en liten, egeneid mekanisme (ikke Mediator) — Enova.Kai.Domain mister sin eneste Mediator-referanse i samme slag. Dette erstatter dagens per-forespørsel-utledning i DbCaseQueries, som tvinger GET /cases og GET /cases/summary til å laste og prosessere hele sakssett i minnet før paginering/telling.

Kontekst⚓︎

GET /cases og GET /cases/summary i Kai Platform er observert trege i test:

Kall Tid
/cases?behandlingsform=krever_gjennomgang&gjennomgatt=false&stadium=soknad&limit=25 10,4 s
/cases?behandlingsform=kan_godkjennes_direkte&gjennomgatt=false&stadium=soknad&limit=25 11 s
/cases?behandlingsform=krever_gjennomgang&gjennomgatt=true&stadium=soknad&limit=25 3,5 s
/cases?behandlingsform=kan_godkjennes_direkte&gjennomgatt=true&stadium=soknad&limit=25 3,2 s
/cases/summary?stadium=soknad 11,3 s

Rotårsak: Behandlingsform/SamletRegelstatus/HarNøytraltUtfall/Gjennomgangsstatus finnes ikke som kolonner på Sak — de utledes ved hver forespørsel fra Regelresultater/Beslutninger/ Stadiegjennomganger via Samlestatuspolicy/Regelrisiko (DbCaseQueries.cs). Fordi behandlingsform/regelstatus ikke er SQL-filtrerbare, laster ListAsync hele det filtrerte sakssettet og projiserer det (kostbart per-rad-arbeid) før paginering skjer i C# — og siden frontend alltid sender behandlingsform sammen med gjennomgatt (fire faner: manuell/AI × aktiv/gjennomgått), er dette normalveien, ikke unntaket. SummaryAsync gjør det samme uforbeholdent for hele stadiet, uansett gjennomgatt, for å telle fram 4 badge-tall. Full utredning: docs/superpowers/specs/2026-09-04-sak-projeksjonskolonner-design.md.

Bekreftet i kodegjennomgang: Samlestatuspolicy.Utled/Regelrisiko.Utled er rene, per-sak-beregninger — ingen kryss-sak-tilstand kreves. Det gjør inkrementell rekalkulering ved skriving (fremfor en periodisk batch/materialized view) mulig og korrekt.

Revidert etter PR-gjennomgang (Bjørn Kristian Punsvik): første utkast av denne ADR-en foreslo en generisk SaveChangesInterceptor som inspiserte ChangeTracker for Regelresultat/Beslutning/ Stadiegjennomgang + en Sak.Status-diff. Tilbakemeldingen var at en slik interceptor virker, men skjuler koblingen for utviklere som ikke kjenner den fra før — «ikke åpenbart at de blir kjørt når man leser koden». Samtidig fantes allerede ferdig bygget, men ubrukt, domenehendelse-infrastruktur i Enova.Kai.Domain/Common/ (Aggregate<TId> med Raise/DomainEvents, IHasDomainEvents, DomainEvent) og en DomainEventDispatchingInterceptor som allerede dispatcher enhver DomainEvent post-commit — men ingen kode i repoet kaller Raise(...) i dag. Å bruke denne i stedet gir samme mekanisme (post-commit, best-effort) med koblingen synlig der en utvikler faktisk leser koden (aggregatets egen metode + en navngitt handler), til omtrent samme kostnad. En fullstendig aggregate-root-sammenslåing (Sak eier Regelresultat/Beslutning/Stadiegjennomgang i én transaksjonsgrense) ble også diskutert og bevisst utsatt — se Ikke-mål.

Videre revidert (samme PR-diskusjon): DomainEvent/DomainEventDispatchingInterceptor var opprinnelig bygget mot Mediator (DomainEvent : INotification, dispatch via Mediators IPublisher). Siden Enova.Kai.Domain allerede har som prinsipp å være fri for infrastruktur- /rammeverksavhengigheter, og siden det denne fiksen faktisk trenger av Mediator er minimalt (type → handler-oppslag + sekvensiell await, ingen pipeline-behaviors — de gjelder kun kommando-dispatch, ikke notification-publish), erstattes Mediator-koblingen på hendelsessiden med en liten, egeneid IDomainEventHandler<T> + IDomainEventDispatcher (§ Beslutning under). Enova.Kai.Domain.csproj mister dermed sin eneste Mediator-referanse. ICommandHandler/ISender (regelmotorens kommando-dispatch) er utenfor scope — beholdes på Mediator som i dag, ingen ambisjon om å fjerne det i denne fiksen.

Beslutning⚓︎

  • Fire nye kolonner på saker: behandlingsform, samlet_regelstatus, har_noeytralt_utfall, gjennomgangsstatus — norske egenskapsnavn på Sak (Behandlingsform, SamletRegelstatus, HarNøytraltUtfall, Gjennomgangsstatus, jf. ADR-015). Oppdatert etter sak-godkjent/avvist (#22207): gjennomgangsstatus ble opprinnelig lagt til som en boolsk gjennomgaatt-kolonne under denne ADR-en, men er siden erstattet av en enum-backet integer-kolonne med tre tilstander (IkkeGjennomgått/Godkjent/Avvist) — ikke forveksle med API-spørreparameteret gjennomgatt, som er en egen, uavhengig wire-navngivingsbeslutning. Ny indeks IX_saker_status_behandlingsform_gjennomgangsstatus på (status, behandlingsform, gjennomgangsstatus).
  • Behandlingsform/AggregateRuleStatus flyttes fra Enova.Kai.Application.Ports.Enums til Enova.Kai.Domain.Cases (samme navn, samme medlemmer — kun namespace/assembly endres). Oppdaget under implementasjonsplanlegging: Sak (Domain) kan ikke ha egenskaper typet med disse enumene mens de ligger i Application — Domain_DoesNotReference_Application (Enova.Kai.Architecture.Tests/LayerDependencyTests.cs) håndhever at Domain aldri refererer Application. Begge enumene er ubiquitous-language-begreper (CONTEXT.md: "Saksnivå-utledning eid av Samlestatuspolicy") som hører hjemme i Domain uansett — flyttingen er en ren namespace-endring (kompilator-verifisert på hvert bruksted, ~30-40 filer får en ny using-linje), ikke en logikkendring. Samlestatuspolicy/Regelrisiko selv (de rene domenetjenestene som utleder disse verdiene) flyttes ikke — det er et eget, større arkitekturspørsmål, bevisst utsatt, ikke avvist, på linje med aggregate-root-diskusjonen over.
  • Én delt, ren kalkulator (SakProjeksjonKalkulator) trekkes ut av dagens DbCaseQueries.ComputeRuleStatus + gjennomgått-oppslaget, og brukes av både backfill-seeder og domenehendelse-handlerne under.
  • Fire nye domenehendelser, raised i aggregatenes/entitetenes egne metoder:
  • RegelresultatOpprettet(RegelresultatId, SakId CaseId, DateTimeOffset OccurredAt) — raised i Regelresultat.Create (arver allerede Raise(...) fra Aggregate<RegelresultatId>).
  • BeslutningRegistrert(Guid BeslutningId, string CaseKey, string StageKey, DateTimeOffset OccurredAt) — raised i Beslutning.Ny.
  • StadiegjennomgangEndret(Guid StadiegjennomgangId, string CaseKey, string StageKey, DateTimeOffset OccurredAt) — raised i Stadiegjennomgang.Ny.
  • SakStatusEndret(SakId CaseId, SaksnummerId Saksnummer, int GammelStatus, int NyStatus, DateTimeOffset OccurredAt) — raised i Sak.OppdaterFraReplika (ikke Anvend selv), kun når felter.Status != Status sammenlignet før Anvend kalles. OppdaterFraReplika kjører kun for eksisterende saker (nye saker går via FraReplika, som aldri raiser — det finnes ingen tidligere projeksjon å ha blitt stale i forhold til). Løser på domenenivå den samme fallgruven som opprinnelig plan måtte løse i en EF-property-sjekk: Anvend setter Status (og stempler LastSyncedAt) på hver synkede sak hver dag, uansett om status faktisk endret seg — uten denne guarden ville hver daglige synk trigget rekalkulering for hele tabellen.
  • Beslutning/Stadiegjennomgang implementerer IHasDomainEvents direkte (et enkelt interface uten ID-type-binding — de trenger ikke bli Aggregate<TId>, kun en liten _domainEvents-liste + Raise/ClearDomainEvents, samme mønster som Aggregate<TId> allerede har). Regelresultat og Sak arver dette allerede via Aggregate<TId>.
  • Ingen ny interceptor. Den eksisterende DomainEventDispatchingInterceptor (src/Enova.Kai.Infrastructure/Persistence/Interceptors/, registrert i samme KaiDbContext som all skriving går gjennom) samler alle fire hendelsestypene uendret via ChangeTracker.Entries<IHasDomainEvents>() — den trenger ingen kjennskap til de nye hendelsene. Eneste endring i interceptoren: den dispatcher via en ny IDomainEventDispatcher i stedet for Mediators IPublisher (se under).
  • Egeneid dispatch, ikke Mediator. Ny IDomainEventHandler<TEvent> (Application, Enova.Kai.Application/Common/) — vårt eget interface, ikke Mediators INotificationHandler<T>. Ett per hendelse: RegelresultatOpprettetHandler, BeslutningRegistrertHandler, StadiegjennomgangEndretHandler, SakStatusEndretHandler (Enova.Kai.Application/Cases/Projection/). Hver slår opp berørt Sak (via CaseId eller CaseKey) gjennom ny port ISakProjeksjonOppdaterer (Application, Infrastructure-adapter), som kjører SakProjeksjonKalkulator og skriver de 4 kolonnene i et lite, målrettet SaveChangesAsync. En ny IDomainEventDispatcher/DomainEventDispatcher (Infrastructure, ved siden av interceptoren) ruter hver hendelse til riktig IDomainEventHandler<TEvent> via IServiceProvider.GetServices<T>() (typerouting løst med dynamic-dispatch inne i dispatcheren — ett sted, ingen sentral switch å huske å oppdatere når en femte hendelse legges til). Alle fire handlere/dispatcheren registreres eksplisitt i DI (services.AddScoped<IDomainEventHandler<X>, XHandler>() per hendelse) — ingen reflection-basert assembly-scanning.
  • To lag med feilhåndtering, ikke ett. Hver handler fanger og logger egne feil internt (App Insights-konvensjon) og svelger dem — uten dette ville en feilet rekalkulering kunnet feile hele den utløsende forespørselen (beslutning/gjennomgang/evaluering/synk), siden verken DomainEventDispatchingInterceptor eller Mediators standard ForeachAwaitPublisher (bekreftet: ren foreach+await, ingen try/catch) fanger unntak selv. IDomainEventDispatcher legger i tillegg et sikkerhetsnett rundt hvert handler-kall — fanger og logger som en tydelig markert feil («handler brøt kontrakten om å fange egne feil») i stedet for å la den propagere og stanse dispatch av øvrige ventende hendelser i samme SaveChangesAsync-batch. Best-effort, ikke transaksjonelt koblet til den utløsende skrivingen — en stale cache retter seg selv ved neste hendelse for saken.
  • DbCaseQueries.ListAsync/SummaryAsync forenkles til rene SQL WHERE/GROUP BY-spørringer over de nye kolonnene — ingen in-memory full-bøtte-lasting for noen kombinasjon av filtre.
  • Én-gangs backfill-seeder (samme konvensjon som RuleConfigurationSeeder), kjører synkront ved oppstart, batcher gjennom saker med NULL-kolonner, idempotent. Migrasjon + domenehendelser + handlere + seeder + omlagt lesevei rulles ut i samme release (seederen blokkerer oppstart til backfill er ferdig).

Ikke-mål⚓︎

  • Omdøpe enum-typen AggregateRuleStatus til et norsk typenavn — kun den nye egenskapen (SamletRegelstatus) på Sak får norsk navn nå; selve enum-typen brukes bredt i regelmotoren og omdøpes eventuelt som egen, senere opprydding.
  • Periodisk reconciliation-/drift-jobb. Vurdert og bevisst valgt bort: hendelsene dekker alle kjente skrivestier, og enhver avdrift retter seg selv ved neste hendelse for den konkrete saken. Revurderes hvis avdrift faktisk observeres i drift.
  • Full aggregate-root-sammenslåing — å la Sak eie Regelresultat/Beslutning/ Stadiegjennomgang som barn-entiteter i én transaksjonsgrense, med én ISakRepository som laster/lagrer hele grafen. Bevisst utsatt, ikke avvist — dette kan godt være riktig retning for domenemodellen på sikt, og bør i så fall vurderes som et eget initiativ (med egen design-diskusjon om konsistensgrense og konkurransemodell), ikke smettes inn som en bieffekt av denne ytelsesfiksen. Grunnen til å ikke ta det med her: Beslutning og Stadiegjennomgang skrives i dag med hver sin optimistiske konkurranse-håndtering, men ikke samme mønster: DbDecisionStore.RecordDecisionAsync fanger unik-konflikten (Postgres 23505) og kaster BeslutningConflictException — ingen retry, klienten må selv prøve på nytt — mens DbStadiegjennomgangStore.SetStatusAsync løser samme konflikt med en intern retry-på-konflikt-løkke (inntil 3 forsøk). Regelresultat skrives med et enkelt insert uten noen tilsvarende konflikthåndtering (RegelresultatRepository.Add + SaveChangesAsync, ingen versjonering) — alle tre har altså ulikt skrivemønster i dag, så en aggregate-rot som skal dekke alle tre må selv designe én felles konsistens-/konkurransemodell, ikke bare flytte eksisterende kode. Det er et reelt design-spørsmål (kontensjon dersom hver append-only-skriving må laste/låse hele Sak-grafen, fare for en «God aggregate» hvis grensa trekkes feil) som fortjener egen tid, ikke et argument mot at det er en dårlig idé. Domenehendelsene over gir den eksplisitte, oppdagbare koblingen denne PR-en trenger nå, uten å forhåndsbestemme svaret på det større spørsmålet.
  • Endre GjennomgåttAv, ReviewedAt, UnderArbeid, PipelineGittOpp eller andre rene visningsfelt — disse er ikke brukt i WHERE/GROUP BY og forblir side-skalerte oppslag i ProjectAsync.
  • Forenkling av GetByKeyAsync til å lese de nye kolonnene er valgfri, ikke del av denne beslutningen.

Konsekvenser⚓︎

  • GET /cases og GET /cases/summary blir O(sidestørrelse) / O(indeksert aggregering) i stedet for O(bøtte-/stadiestørrelse) — fjerner rotårsaken til observert 3-11 s responstid.
  • Koblingen mellom skriving og projeksjonsoppdatering blir synlig i koden man faktisk leser: Raise(new RegelresultatOpprettet(...)) i aggregatets egen metode, og IDomainEventHandler<RegelresultatOpprettet> som en navngitt, grep-bar, eksplisitt DI-registrert klasse — i motsetning til en generisk interceptor som inspiserer ChangeTracker for urelaterte typer.
  • Enova.Kai.Domain mister sin eneste Mediator-referanse (Mediator.Abstractions) — DomainEvent slutter å implementere INotification. Domenelaget er dermed fritt for infrastruktur-/rammeverksavhengigheter igjen, i tråd med det uttalte prinsippet ("no infra deps"). ICommandHandler/ISender i Application-laget (regelmotorens kommando-dispatch) er urørt og fortsatt på Mediator — kun hendelsessiden flyttes.
  • Ny, liten infrastrukturflate å vedlikeholde: IDomainEventHandler<T> + IDomainEventDispatcher er kode teamet selv eier og må forstå (typerouting via dynamic, sikkerhetsnett-try/catch) i stedet for å lene seg på et bibliotek. Motvekt: overflaten er reelt liten (én dispatcher-klasse, ingen pipeline/behaviors), og fjerner en avhengighet fremfor å legge til en.
  • Ny vedlikeholdsflate, nå på domenenivå i stedet for infrastrukturnivå: enhver ny kode som oppretter en Regelresultat/Beslutning/Stadiegjennomgang utenom deres egne fabrikkmetoder (Regelresultat.Create, Beslutning.Ny, Stadiegjennomgang.Ny), eller som muterer Sak.Status utenom Sak.OppdaterFraReplika, raiser ingen hendelse og blir ikke fanget opp. I dag er disse fabrikkmetodene allerede eneste inngang (ingen annen kode konstruerer disse entitetene direkte), så risikoen er lav, men verdt å nevne som en invariant å bevare.
  • Inkonsistens-vinduet er kortvarig (sub-forespørsel) for de tre entitetshendelsene (RegelresultatOpprettet/BeslutningRegistrert/StadiegjennomgangEndret): rekalkulering skjer i et eget, påfølgende SaveChangesAsync etter den utløsende commiten, innenfor samme forespørsel — ikke atomisk med den, men samme post-commit-timing som DomainEventDispatchingInterceptor allerede bruker for alt annet den dispatcher. For en fersk Sak fra Mimir-synkroniseringen er vinduet derimot ikke sub-forespørsel: FraReplika raiser ingen hendelse (se over), så den faktiske evalueringen ikke er reflektert før Workerens separate, time-syklede discovery+evaluate-pipeline faktisk behandler saken — realistisk fra minutter til rundt en time senere. Fast-follow (denne branchen): Sak.FraReplika seeder nå samme «ingen evaluering ennå»-default som Samlestatuspolicy.Utled([]) selv ville gitt (Krever gjennomgang/Undetermined) i stedet for å la kolonnene stå null, så en fersk sak i det minste er synlig under riktig standard-fane med én gang — men avviker den faktiske evalueringen fra defaulten (f.eks. saken viser seg å kunne godkjennes direkte), er det ikke reflektert før evalueringen er ferdig. Begge vinduene er vurdert akseptable — systemet har allerede et større asynkront gap i dag (re-evaluering etter en beslutning køes fire-and-forget til worker).
  • Filtrering/telling (ListAsync/SummaryAsync) og visning (ProjectAsync) er nå to atskilte beregninger i stedet for én, og kan i sjeldne, forbigående tilfeller derfor vise ulik klassifisering for samme sak: ListAsync/SummaryAsync filtrerer/teller på de persisterte projeksjonskolonnene, mens ProjectAsync fortsatt rekalkulerer Behandlingsform/ SamletRegelstatus osv. live, per forespørsel, fra de samme underliggende kilderadene (evalueringer/beslutninger/gjennomganger), for å bygge selve visningsdataene for siden. Under normal drift er de enige (samme input, samme logikk), men to hendelser kan skille dem en kort stund: (a) en handler som fanger og logger sin egen feil i stedet for å propagere den (bevisst valgt, se over) etterlater den persisterte kolonnen stale mens «ville-vært»-live-beregningen allerede har flyttet seg videre; (b) en fremtidig regelendring i Risikopolicy/ Samlestatuspolicy endrer hva live-beregningen ville gitt, mens SakProjeksjonBackfillSeeder kun treffer rader med SamletRegelstatus IS NULL og aldri regner om allerede tilbakefylte rader (kun BulkReevaluationJob, en egen eksisterende mekanisme, trigger det). I begge vinduene kan en sak vises i én filtrert fane (styrt av den persisterte kolonnen) mens dens egen detaljvisning/ badge (styrt av live-rekalkuleringen) viser en annen klassifisering — strukturelt umulig før denne ADR-en, siden én og samme beregning drev begge. I tråd med prinsippet over («retter seg selv ved neste hendelse») er dette ikke en ny korrekthetsrisiko som krever en reconciliation-jobb — bare en dokumentasjonsluke: denne konkrete avdriftsformen var ikke tidligere nevnt.
  • Fire nye kolonner + én ny indeks på saker; én engangs-backfill ved neste oppstart (kan øke oppstartstid — radantall i test/prod ikke verifisert ved skrivetidspunkt, sjekk før prod-utrulling).
  • Forkastet: periodisk materialized-view/batch-refresh (introduserer synlig staleness som ikke passer UX-en der en saksbehandlers handling — beslutning/gjennomgang-markering — forventes reflektert umiddelbart); købasert asynkron rekalkulering via TickerQ (unødvendig kompleksitet for en beregning som allerede er billig og ren per sak); atomisk in-transaction-rekalkulering i SavingChangesAsync (krever å lese allerede committerte relaterte rader midt i en pågående SaveChanges, mer skjørt mot EFs lokale/lager-spørringsnyanser enn en enkel post-commit-oppfølger); en generisk SaveChangesInterceptor som inspiserer ChangeTracker direkte (opprinnelig plan i denne ADR-en — erstattet av domenehendelser etter PR-tilbakemelding, se Kontekst). Full aggregate-root-sammenslåing er ikke i denne listen — den er bevisst utsatt, ikke forkastet; se Ikke-mål.

Verifisering⚓︎

  • [x] Migrasjon legger til behandlingsform, samlet_regelstatus, har_noeytralt_utfall, gjennomgangsstatus (enum-backet integer, tre tilstander — se oppdatering over) på saker
    • indeks IX_saker_status_behandlingsform_gjennomgangsstatus på (status, behandlingsform, gjennomgangsstatus) — dekket av MigrationGuardTests (Architecture.Tests).
  • [x] SakProjeksjonKalkulator er en ren, testet funksjon brukt av både seeder og IDomainEventHandler<T>-implementasjonene.
  • [x] De fire domenehendelsene raises korrekt: RegelresultatOpprettet fra Regelresultat.Create, BeslutningRegistrert fra Beslutning.Ny, StadiegjennomgangEndret fra Stadiegjennomgang.Ny, SakStatusEndret fra Sak.OppdaterFraReplika — dekket av domenelag-enhetstester. FraReplika (ny sak) raiser aldri SakStatusEndret.
  • [x] Beslutning/Stadiegjennomgang implementerer IHasDomainEvents; eksisterende DomainEventDispatchingInterceptor dispatcher dem uendret (ingen ny interceptor lagt til) — via ny IDomainEventDispatcher, ikke Mediators IPublisher.
  • [x] DomainEvent implementerer ikke lenger INotification; Enova.Kai.Domain.csproj har ingen Mediator-pakkereferanse — dekket av LayerDependencyTests (Architecture.Tests).
  • [x] SakStatusEndret raises kun når Status faktisk endres — regresjonstest som simulerer en full daglig synk uten statusendring og bekrefter at ingen hendelse raises / ingen rekalkulering skjer.
  • [x] IDomainEventHandler<T> for hver av de fire hendelsene rekalkulerer korrekt og skriver kolonnene — integrasjonstester mot ekte Postgres (Testcontainers), inkludert Sak.Status-endring på tvers av stadiegrense (Søknad → Gjennomføring).
  • [x] Handler-feil logges og svelges internt — feiler ikke den utløsende forespørselen. I tillegg: IDomainEventDispatchers eget sikkerhetsnett fanger en handler som IKKE fanger sin egen feil, logger det som avvik, og fortsetter å dispatche øvrige ventende hendelser i samme batch (regresjonstest: to hendelser i én SaveChangesAsync, første handler kaster — andre hendelsen skal likevel dispatches).
  • [x] DbCaseQueries.ListAsync/SummaryAsync har ingen gjenværende ToListAsync-før-Skip/Take-gren for behandlingsform/regelstatus.
  • [x] Backfill-seeder er idempotent (trygg å kjøre flere ganger) og kjører til fullført før appen tar imot trafikk.
  • [ ] De 5 opprinnelig trege kallene er re-kjørt mot test og viser vesentlig lavere responstid. Ikke verifisert ennå: krever brukerens egen tilgang til et faktisk deployet testmiljø med realistiske datavolum — sporet separat som Task 16 steg 4 i docs/superpowers/plans/2026-09-07-sak-projeksjonskolonner.md.
  • [x] dotnet build Kai.slnx grønt (0 warnings), og full testsuite (~2262 tester) grønn, inkludert arkitektur-lagavhengighetstestene og MigrationGuardTests.

Bygger på ADR-011, ADR-015, ADR-016 (nærmeste eksisterende analogi — daglig batch-replika, annen mekanisme enn hendelsesdrevet inkrementell oppdatering) og ADR-019. Design: docs/superpowers/specs/2026-09-04-sak-projeksjonskolonner-design.md.