<?xml version="1.0"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>INIO</title>
    <link>https://www.inio.no</link>
    <atom:link href="https://www.inio.no/feed.xml" rel="self" type="application/rss+xml" />
    <description>INIO</description>
    <language>en-gb</language>
    <pubDate>Mon, 19 Oct 2026 10:45:35 +0000</pubDate>
    <lastBuildDate>Mon, 19 Oct 2026 10:45:35 +0000</lastBuildDate>

    <item>
      <title>Datainnbrudd i Lørenskog: skaden har skjedd, hva nå?</title>
      <link>https://www.inio.no/blog/2026-10-12-datainnbrudd-i-lorenskog-hva-na.html</link>
      <pubDate>Mon, 12 Oct 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-10-12-datainnbrudd-i-lorenskog-hva-na.html</guid>
      <description>
	&lt;p&gt;Det fine med historien er at vi kan lære av den. Det betyr ikke at historien er vakker, ei heller at den ender godt. Her er en historie relatert til datainnbruddet i Lørenskog kommune.&lt;/p&gt;
&lt;h2&gt;Det som kommer etterpå&lt;/h2&gt;
&lt;p&gt;Her er hva du kan risikere å møte dersom du er blant dem som ligger i de «tapte» 1,8 terabytene med data – eller omtrent 1,3 millioner filer.&lt;/p&gt;
&lt;p&gt;Historien vi skal lære av, begynner i Finland. Psykoterapiselskapet Vastaamo ble utsatt for et datainnbrudd i 2018, og høsten 2020 begynte utpressingen. Pasientopplysninger om rundt 33 000 personer var stjålet.&lt;/p&gt;
&lt;p&gt;Lørenskog har rundt 50 000 innbyggere. Vi vet ennå ikke hvor mange av dem som er berørt, men kommunen og mediene snakker om flere tusen, og materialet inneholder blant annet pasientjournaler fra psykisk helse og barnevernssaker. Det er samme type opplysninger som i Vastaamo.&lt;/p&gt;
&lt;p&gt;Innholdet i filene har også en ubehagelig likhet. Det dreier seg ikke bare om navn og kontaktinformasjon, men om svært sensitive personopplysninger – opplysninger om psykisk helse, familierelasjoner og private forhold som mennesker med god grunn forventer at skal forbli mellom dem og behandleren.&lt;/p&gt;
&lt;p&gt;Det er et viktig poeng i denne sammenhengen.&lt;/p&gt;
&lt;p&gt;Vastaamo-saken (utpressing) er nå seks år gammel. I 2020 ble det kjent at opplysninger om mer enn 30 000 pasienter var stjålet. Senere retts- og medieomtale har lagt tallet rundt 33 000. I 2020 var kunstig intelligens fortsatt noe de fleste av oss først og fremst forbandt med forskning, selvkjørende biler og science fiction.&lt;/p&gt;
&lt;p&gt;Angrepsverktøyene var allerede effektive. Men verktøyene vi har i dag er ikke de samme som i 2020. Jeg tror derfor vi bør være forsiktige med å bruke Vastaamo som et tak på hva som kan skje med Lørenskog-dataene.&lt;br /&gt;
Vastaamo viser hva som kunne skje i 2020. Det er ikke nødvendigvis en beskrivelse av hva som kan skje i 2026.&lt;br /&gt;
Og når store mengder svært sensitive personopplysninger først er på avveie, er det derfor ikke sikkert at den største risikoen ligger i det som kan gjøres med informasjonen nå. Risikoen ligger også i hva som skjer når informasjonen berikes med data fra sosiale nettverk og åpne kilder, og hva som blir mulig å gjøre med det samlede bildet i morgen, eller om fem eller ti år.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Hva &lt;/th&gt;&lt;th align=&quot;right&quot;&gt; Omtrent &lt;/th&gt;&lt;th&gt; Kilde &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; Pasienter med stjålne journaler &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; 33 000 &lt;/td&gt;&lt;td&gt; Yle &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Ofre i straffesaken &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; over 21 000 &lt;/td&gt;&lt;td&gt; Yle, februar 2024 &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Tiltalepunkter for forsøk på grov utpressing &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; over 21 000 &lt;/td&gt;&lt;td&gt; Yle, mars 2024 &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Dømt for utpressing der offeret betalte &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; 20 tilfeller &lt;/td&gt;&lt;td&gt; Yle, lagmannsretten &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Ofre som har søkt statlig erstatning &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; rundt 6 000 &lt;/td&gt;&lt;td&gt; Yle, juni 2026 &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Ofre som inngikk forlik med gjerningsmannen &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; 1 200 &lt;/td&gt;&lt;td&gt; Yle, mars 2024 &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Selvmord etter lekkasjen &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; ukjent; en advokat for ofrene kjenner til tilfeller &lt;/td&gt;&lt;td&gt; Yle, mars 2024 &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Andre konsekvenser (frykt, sykmelding, isolasjon) &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; ikke registrert &lt;/td&gt;&lt;td&gt; se under &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;NB&lt;/strong&gt; &lt;em&gt;Tallene bør leses som størrelsesordener, ikke som et komplett skadeomfang. Fravær av dokumentasjon er ikke det samme som fravær av hendelser.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Om betalingene&lt;/h2&gt;
&lt;p&gt;Vi vet at det gikk betalinger til hackerens bitcoin-lommebok. Det er imidlertid uklart hvor mange det var, og om alle kom fra ofrene.&lt;/p&gt;
&lt;p&gt;Det er derfor viktig å skille mellom &lt;strong&gt;observerte betalinger og antall ofre som betalte&lt;/strong&gt;. Det første kan vi telle; det andre vet vi ikke.&lt;/p&gt;
&lt;p&gt;Vi vet også at utpressingen var omfattende. I den senere straffesaken ble det reist over 21 000 tiltalepunkter for forsøk på grov utpressing. Gjerningsmannen ble i tillegg dømt for 20 tilfeller der utpressingen faktisk førte til betaling.&lt;/p&gt;
&lt;h2&gt;Om selvmord og selvmordsforsøk&lt;/h2&gt;
&lt;p&gt;Her er tallene om mulig enda vagere.&lt;/p&gt;
&lt;p&gt;Under rettssaken opplyste advokat Jenni Raiskio, som representerte ofre, at hun hadde blitt kontaktet av personer hvis pårørende hadde tatt livet sitt etter at pasientopplysningene ble stjålet og publisert. Hun fortalte også at andre ofre hadde vurdert selvmord etter lekkasjen.&lt;/p&gt;
&lt;p&gt;Det betyr ikke at vi kan slå fast at lekkasjen var årsaken i de enkelte tilfellene. Det betyr heller ikke at dette er et komplett tall. Det er rapporterte tilfeller fra en advokat som representerte rundt 1 500 ofre.&lt;/p&gt;
&lt;p&gt;Jeg har heller ikke funnet en samlet offentlig undersøkelse av langtidsvirkningene for denne gruppen. Det er mulig at forskning på traumeoppfølging etter andre store katastrofer kan gi et bedre grunnlag for å forstå slike langtidsvirkninger. Men det vil være en sammenligning, ikke dokumentasjon av hva som faktisk skjedde med Vastaamo-ofrene.&lt;/p&gt;
&lt;p&gt;Det er nettopp dette som gjør tallene vanskelige å bruke: Vi har ganske gode tall på hvor mange filer som ble stjålet. Vi har langt dårligere tall på hva tyveriet gjorde med menneskene filene handlet om.&lt;/p&gt;
&lt;h2&gt;Andre dokumenterte konsekvenser&lt;/h2&gt;
&lt;p&gt;Det finnes likevel en rekke beskrivelser av konkrete konsekvenser for ofrene:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sykmelding og sosial isolasjon; enkelte ofre har fortalt at de sluttet å gå ut eller tok fri fra arbeid&lt;/li&gt;
&lt;li&gt;frykt for at arbeidsgivere, familie og bekjente skulle få tilgang til journalopplysningene&lt;/li&gt;
&lt;li&gt;frykt for identitetstyveri og behov for å beskytte personnummer og andre identifikasjonsopplysninger&lt;/li&gt;
&lt;li&gt;angst, skam og mistillit til helsevesenet&lt;/li&gt;
&lt;li&gt;personer som ikke lenger ønsket å oppsøke psykoterapi&lt;/li&gt;
&lt;li&gt;videre spredning av journalopplysninger på nettet, også etter at det opprinnelige innbruddet var kjent&lt;/li&gt;
&lt;li&gt;over 21 000 ofre i straffesaken; rundt 6 000 har søkt statlig erstatning, og 1 200 inngikk forlik med gjerningsmannen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Erstatningssystemet gir også et bilde av hvor vanskelig det er å sette en verdi på skaden. Statens erstatningsordning vurderte i utgangspunktet erstatningene til rundt 500–1 500 euro, avhengig av hvilken type opplysninger som var eksponert. I juni 2026 økte finsk forsikringsdomstol erstatningene i 11 saker, til 1 000–2 000 euro.&lt;/p&gt;
&lt;p&gt;Det er verdt å merke seg hva disse summene faktisk forsøker å prise: ikke verdien av de stjålne filene, men lidelsen og belastningen som følger av at svært private opplysninger er gjort tilgjengelige for andre.&lt;/p&gt;
&lt;h2&gt;Vær beredt&lt;/h2&gt;
&lt;p&gt;Med blikket bakover håper jeg helsevesenet og krisestabene allerede har klare veiledere og kapasitet for det som kan møte ofrene nå. Ikke selve datainnbruddet, men for det som kommer etterpå.&lt;/p&gt;
&lt;h3&gt;Utpressingskrav:&lt;/h3&gt;
&lt;p&gt;Vastaamo viser at titusenvis av enkeltpersoner kan bli direkte mål for utpressing. I dag finnes det langt bedre verktøy for å søke i, sammenstille og korrelere store datamengder enn i 2020. Det gjør det mulig å automatisere en type målretting som tidligere krevde betydelig manuelt arbeid.&lt;/p&gt;
&lt;h3&gt;Betalinger:&lt;/h3&gt;
&lt;p&gt;Vi vet at det i Vastaamo-saken ble gjort betalinger til bitcoin-adresser knyttet til utpressingen. Gjerningsmannen er dømt for 20 tilfeller der offeret betalte, men hvor mange som faktisk betalte, er fortsatt uklart.&lt;/p&gt;
&lt;h3&gt;Personlig målretting:&lt;/h3&gt;
&lt;p&gt;Det mest alvorlige er kanskje ikke at noen kan finne en fil. Det er at informasjon fra mange filer kan sammenstilles. Familieforhold, navn, adresser, økonomi, helseopplysninger og andre identifikatorer kan i prinsippet settes sammen til et langt mer presist bilde av et menneske enn den enkelte journalen alene gir.&lt;/p&gt;
&lt;h3&gt;Selvmord og alvorlige psykiske reaksjoner:&lt;/h3&gt;
&lt;p&gt;Vastaamo-saken dokumenterer at ofre og deres pårørende rapporterte svært alvorlige reaksjoner, inkludert selvmord og selvmordstanker. Vi vet ikke hvor stort dette omfanget faktisk var. Nettopp derfor bør det ikke komme som en overraskelse dersom også Lørenskog-saken får konsekvenser som ikke lar seg fange i statistikken.&lt;/p&gt;
&lt;h3&gt;Arbeidstap og andre sosiale konsekvenser:&lt;/h3&gt;
&lt;p&gt;Vi har dokumenterte enkelthistorier om sykmelding, isolasjon og frykt for at opplysningene skal bli kjent. Vi har derimot ikke et samlet tall på hvor mange som fikk problemer i arbeidsliv, familie eller sosialt liv.&lt;/p&gt;
&lt;p&gt;Det er kanskje den viktigste lærdommen fra Vastaamo.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Vi vet hvordan vi teller filer. Vi vet hvordan vi teller berørte personer. Vi er langt dårligere til å telle hva som skjer med menneskene etterpå.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;En liten betraktning på tampen&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Utpressing er også en økonomisk øvelse.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I Vastaamo-saken fikk over 20 000 personer krav om å betale 200 euro innen 24 timer, eller 500 euro innen 48 timer. Det tilsvarer en potensiell kravmasse på mellom fire og ti millioner euro. Gjerningsmannen ble dømt for 20 tilfeller der offeret betalte.&lt;/p&gt;
&lt;p&gt;Det interessante er derfor ikke bare hvor mye hver enkelt kan bli presset for. Det er hvor liten andel av ofrene som trenger å betale før masseutpressing blir lønnsomt.&lt;/p&gt;
&lt;p&gt;Med for eksempel 30 000 berørte personer ville et tilsvarende krav på 200–500 euro representere en teoretisk kravmasse på 6–15 millioner euro. Det er ikke en prognose for Lørenskog, men det illustrerer hvorfor store datamengder kan ha en annen verdi for en angriper enn den har for virksomheten som mistet dem.&lt;/p&gt;
&lt;h2&gt;Om arbeidsprosessen&lt;/h2&gt;
&lt;p&gt;Artikkelen bygger på egne observasjoner, erfaringer og undersøkelser. Jeg har selv valgt problemstilling, kilder og sammenlikningsgrunnlag, og utarbeidet analysen og tabellene. KI er brukt som redaksjonelt hjelpemiddel til blant annet språkvask, rettskriving, oversettelse og sammenstilling av kildemateriale.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Om selve hendelsen: &lt;a href=&quot;https://www.lorenskog.kommune.no/it-hendelse/&quot;&gt;https://www.lorenskog.kommune.no/it-hendelse/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Innbyggertall, Lørenskog kommune: &lt;a href=&quot;https://www.lorenskog.kommune.no/postliste-og-innsyn/kommunens-planer/folkehelseoversikt-2024-2027/befolkningssammensetning/&quot;&gt;https://www.lorenskog.kommune.no/postliste-og-innsyn/kommunens-planer/folkehelseoversikt-2024-2027/befolkningssammensetning/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NRK om innholdet i de stjålne dataene: &lt;a href=&quot;https://www.nrk.no/stor-oslo/xl/folsom-info-om-tusenvis-av-innbyggere-ble-funnet-pa-det-morke-nettet-1.17963757&quot;&gt;https://www.nrk.no/stor-oslo/xl/folsom-info-om-tusenvis-av-innbyggere-ble-funnet-pa-det-morke-nettet-1.17963757&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Yle – Vastaamo-saken og erstatningssakene (engelsk og finsk)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/3-12152682&quot;&gt;https://yle.fi/a/3-12152682&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/t/18-318971/en&quot;&gt;https://yle.fi/t/18-318971/en&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/7-10082735&quot;&gt;https://yle.fi/a/7-10082735&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/74-20077285&quot;&gt;https://yle.fi/a/74-20077285&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/74-20233310&quot;&gt;https://yle.fi/a/74-20233310&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/74-20075316&quot;&gt;https://yle.fi/a/74-20075316&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/74-20078417&quot;&gt;https://yle.fi/a/74-20078417&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yle.fi/a/74-20212466&quot;&gt;https://yle.fi/a/74-20212466&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;BBC omtale av ofrene: &lt;a href=&quot;https://www.bbc.com/news/articles/c62nzxqw45eo&quot;&gt;https://www.bbc.com/news/articles/c62nzxqw45eo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Helsinki-times: &lt;a href=&quot;https://www.helsinkitimes.fi/finland/finland-news/domestic/29048-vastaamo-hacker-wanted-after-appeal-rejected.html&quot;&gt;https://www.helsinkitimes.fi/finland/finland-news/domestic/29048-vastaamo-hacker-wanted-after-appeal-rejected.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>Ansvarlighetsmatrise</title>
      <link>https://www.inio.no/blog/2026-10-02-ansvarlighetsmatrise.html</link>
      <pubDate>Fri, 2 Oct 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-10-02-ansvarlighetsmatrise.html</guid>
      <description>
	&lt;p&gt;&lt;em&gt;Et arbeidsdokument, ikke juridisk rådgivning.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Tabellen samler situasjoner der informasjonssikkerhet svikter i offentlig sektor, og hvilke lover som kan gi den enkelte et personlig ansvar. Den er kontrollert mot lovteksten og forarbeidene, men skal også prøves av folk som arbeider med dette til daglig.&lt;/p&gt;
&lt;p&gt;Jeg vil bruke den som grunnlag for å vurdere risiko for den enkelte – for helsearbeideren og for lederen: hva kan skje, hvor ofte skjer det, og hva står på spill.&lt;/p&gt;
&lt;p&gt;Ser du en feil, en lov som mangler eller en situasjon som bør med? Still spørsmålet på &lt;a href=&quot;https://www.linkedin.com/in/herman-seip-3004632/&quot; rel=&quot;noopener noreferrer&quot;&gt;LinkedIn&lt;/a&gt;, eller send meg en melding på &lt;a href=&quot;/contact.html&quot;&gt;Signal&lt;/a&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Situasjon / feil &lt;/th&gt;&lt;th&gt; Mulig pliktbrudd &lt;/th&gt;&lt;th&gt; Individuelt ansvar mulig? &lt;/th&gt;&lt;th&gt; Mulig rettsgrunnlag &lt;/th&gt;&lt;th&gt; Strafferamme / reaksjon &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; Bevisst gir uvedkommende tilgang til taushetsbelagte opplysninger &lt;/td&gt;&lt;td&gt; Taushetsbrudd &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 209, jf. forvaltningsloven § 13 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;1 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Grovt uaktsomt lar taushetsbelagte opplysninger komme på avveie &lt;/td&gt;&lt;td&gt; Taushetsbrudd (plikten til å hindre at andre får adgang) &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 209 fjerde ledd, jf. forvaltningsloven § 13 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;1 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Grovt taushetsbrudd: forsett om vinning, eller tap eller fare for tap for noen &lt;/td&gt;&lt;td&gt; Grovt taushetsbrudd &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt;, ved forsett &lt;/td&gt;&lt;td&gt; Straffeloven § 210 &lt;/td&gt;&lt;td&gt; Inntil &lt;strong&gt;3 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Den som utøver eller bistår ved utøving av offentlig myndighet, bryter grovt en tjenesteplikt &lt;/td&gt;&lt;td&gt; Tjenestefeil &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 171 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;2 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Som over, men grovt uaktsomt &lt;/td&gt;&lt;td&gt; Grovt uaktsom tjenestefeil &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 172 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;1 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Tjenesteplikten brytes ved utøving av offentlig myndighet, med alvorlig ulempe eller skade som følge &lt;/td&gt;&lt;td&gt; Misbruk av offentlig myndighet &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 173 bokstav c &lt;/td&gt;&lt;td&gt; Inntil &lt;strong&gt;6 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Bevisst uriktig opplysning til offentlig myndighet under forklaringsplikt &lt;/td&gt;&lt;td&gt; Uriktig forklaring &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 221 første ledd bokstav c &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;2 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Grovt uaktsomt unnlater lovpålagt personvernhandling &lt;/td&gt;&lt;td&gt; Personvernbrudd &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ikke etter personvernreglene&lt;/strong&gt;; mulig etter straffeloven § 209 &lt;/td&gt;&lt;td&gt; GDPR / personopplysningsloven (ingen straffebestemmelse) &lt;/td&gt;&lt;td&gt; Overtredelsesgebyr mot virksomheten &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; GDPR-sikkerheten er ikke egnet for risikoen &lt;/td&gt;&lt;td&gt; Brudd på art. 32 &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Normalt ikke direkte straff&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; GDPR art. 32, jf. personopplysningsloven § 26 &lt;/td&gt;&lt;td&gt; Overtredelsesgebyr mot virksomheten &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Brudd meldes ikke til Datatilsynet uten ugrunnet opphold, og når det er mulig senest innen 72 timer &lt;/td&gt;&lt;td&gt; Art. 33 &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Normalt virksomhetsansvar&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; GDPR art. 33, jf. personopplysningsloven § 26 &lt;/td&gt;&lt;td&gt; Overtredelsesgebyr mot virksomheten &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Berørte personer varsles ikke når bruddet sannsynligvis gir høy risiko for dem &lt;/td&gt;&lt;td&gt; Art. 34 &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Normalt virksomhetsansvar&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; GDPR art. 34, jf. personopplysningsloven § 26 &lt;/td&gt;&lt;td&gt; Overtredelsesgebyr mot virksomheten &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Ledelsen kjenner til alvorlig risiko og gjør ingenting &lt;/td&gt;&lt;td&gt; Mulig tjenestefeil &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Mulig&lt;/strong&gt;, hvis de utøver eller bistår ved utøving av offentlig myndighet &lt;/td&gt;&lt;td&gt; Straffeloven §§ 171 og 172 &lt;/td&gt;&lt;td&gt; Inntil &lt;strong&gt;1–2 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Kommunedirektøren lar være å etablere eller følge opp internkontroll &lt;/td&gt;&lt;td&gt; Kommuneloven § 25-1 &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja som ansvar&lt;/strong&gt;, men kommuneloven har ingen straffebestemmelse &lt;/td&gt;&lt;td&gt; Kommuneloven § 25-1 &lt;/td&gt;&lt;td&gt; Administrativt og politisk ansvar &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Kommunen mangler rutiner for å avdekke og følge opp avvik og risiko for avvik &lt;/td&gt;&lt;td&gt; Kommuneloven § 25-1 tredje ledd bokstav b og c &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja som ansvar&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Kommuneloven § 25-1 &lt;/td&gt;&lt;td&gt; Som over &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Kommunedirektøren vet om risiko, men sørger ikke for oppfølging &lt;/td&gt;&lt;td&gt; Internkontrollsvikt &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Potensielt&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Kommuneloven § 25-1, og straffeloven §§ 171 og 172 hvis vilkårene der er oppfylt &lt;/td&gt;&lt;td&gt; Inntil &lt;strong&gt;1–2 år&lt;/strong&gt; etter §§ 171 og 172 &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Noen, også helsepersonell, tilegner seg helseopplysninger uberettiget, forsettlig eller grovt uaktsomt &lt;/td&gt;&lt;td&gt; Urettmessig tilegnelse &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Pasientjournalloven §§ 16 og 30 a; for helsepersonell også helsepersonelloven § 67 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;1 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Grov urettmessig tilegnelse av helseopplysninger &lt;/td&gt;&lt;td&gt; Samme &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Pasientjournalloven § 30 a andre ledd; helsepersonelloven § 67 &lt;/td&gt;&lt;td&gt; Inntil &lt;strong&gt;3 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Sikkerhetsloven gjelder, og kravet om sikkerhetstiltak brytes forsettlig eller uaktsomt &lt;/td&gt;&lt;td&gt; Sikkerhetsbrudd &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt;, der loven treffer (den verner nasjonale sikkerhetsinteresser) &lt;/td&gt;&lt;td&gt; Sikkerhetsloven § 4-3, jf. § 11-4 første ledd &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;6 måneder&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Taushetsplikt om sikkerhetsgradert informasjon brytes forsettlig eller grovt uaktsomt &lt;/td&gt;&lt;td&gt; Taushetsplikt &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Sikkerhetsloven § 5-4 andre ledd, jf. § 11-4 andre ledd &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;1 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Ansvarlig leder etter sikkerhetsloven unnlater risikovurdering og dokumentasjon &lt;/td&gt;&lt;td&gt; Brudd på sikkerhetsstyring &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ikke straffbart i seg selv&lt;/strong&gt;; brudd på pålegg fra tilsynet er straffbart &lt;/td&gt;&lt;td&gt; Sikkerhetsloven §§ 4-1, 4-2 og 4-4; pålegg etter § 3-6, jf. § 11-4 første ledd &lt;/td&gt;&lt;td&gt; Pålegg; ved brudd på pålegg bot / inntil &lt;strong&gt;6 måneder&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Angriper bryter seg inn i kommunens datasystem &lt;/td&gt;&lt;td&gt; Datainnbrudd &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja – angriperen&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 204 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;2 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Angriper får uberettiget tilgang til informasjon som overføres elektronisk &lt;/td&gt;&lt;td&gt; Krenkelse av kommunikasjonsvernet &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja – angriperen&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 205 bokstav b &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;2 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Angriper skaffer seg tilgangsdata for å begå et lovbrudd &lt;/td&gt;&lt;td&gt; Tilgangsdata &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Ja – angriperen&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Straffeloven § 201 &lt;/td&gt;&lt;td&gt; Bot / inntil &lt;strong&gt;1 år&lt;/strong&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

      </description>
    </item>
    <item>
      <title>Blue/Green on Paper: Upgrading Forgejo Seven Major Versions</title>
      <link>https://www.inio.no/blog/2026-10-01-forgejo-blue-green-upgrade.html</link>
      <pubDate>Thu, 1 Oct 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-10-01-forgejo-blue-green-upgrade.html</guid>
      <description>
	&lt;h1&gt;Blue/Green on Paper: Upgrading Forgejo Seven Major Versions&lt;/h1&gt;
&lt;p&gt;Our self-hosted &lt;a href=&quot;https://forgejo.org/&quot;&gt;Forgejo&lt;/a&gt; runs the fleet&apos;s git: every&lt;br /&gt;
repository, the CI webhooks, the container registry, the agents&apos; issue&lt;br /&gt;
tracker. On 1 October 2026 it was running 9.0.3. Upstream was at 16.0.5.&lt;/p&gt;
&lt;p&gt;Nobody had decided to stay on 9. An upgrade had been proposed in May, as a&lt;br /&gt;
blue/green switch, and the pull request was closed without being merged.&lt;br /&gt;
Nothing noticed: no check compared what we pinned with what existed&lt;br /&gt;
upstream, so the gap grew quietly for five months.&lt;/p&gt;
&lt;h2&gt;Blue/green on paper&lt;/h2&gt;
&lt;p&gt;Our architecture decision record lists Forgejo as a blue/green service: two&lt;br /&gt;
slots, one live, one idle; upgrade the idle one, switch traffic with a DNS&lt;br /&gt;
change, keep the old one as the rollback. When we went to use it, we found&lt;br /&gt;
it described a system that did not exist.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/blog/forgejo-blue-green/before.svg&quot; alt=&quot;Before: every client used the server&apos;s IP address&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;There was no blue slot. The address the May plan reserved for it had since&lt;br /&gt;
been given to a database server.&lt;/li&gt;
&lt;li&gt;Every client reached Forgejo by its &lt;strong&gt;IP address&lt;/strong&gt;: the reverse proxies,&lt;br /&gt;
the CI server, the agents&apos; scripts, the apps that file bug reports, the&lt;br /&gt;
CI pipelines that push back to their own repositories, and every git&lt;br /&gt;
clone on every host. About 45 places across 14 repositories.&lt;/li&gt;
&lt;li&gt;A DNS name existed, and the old runbook&apos;s cutover step was &amp;quot;change the DNS&lt;br /&gt;
record&amp;quot;. It would have moved almost nothing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The operator&apos;s call was to fix that properly first: every client reaches&lt;br /&gt;
Forgejo by name, and the cutover is a DNS change, as designed.&lt;/p&gt;
&lt;h2&gt;One name, two slots&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;/img/blog/forgejo-blue-green/after.svg&quot; alt=&quot;After: clients use one internal name; one variable says which slot is live&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The work, in the order it mattered:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One internal-only name.&lt;/strong&gt; We picked a name that exists only in internal&lt;br /&gt;
DNS. A second alias also existed in public DNS, pointing at the public&lt;br /&gt;
proxy; a host that fell back to a public resolver would have sent git&lt;br /&gt;
traffic there. With an internal-only name, a wrong resolver fails loudly&lt;br /&gt;
instead of misrouting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One variable decides which slot is live.&lt;/strong&gt; The DNS role now renders both&lt;br /&gt;
Forgejo names from a single value. Our DNS answers with TTL 0, so the&lt;br /&gt;
switch is seen immediately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The proxies resolve per request.&lt;/strong&gt; nginx resolves a hostname in&lt;br /&gt;
&lt;code&gt;proxy_pass&lt;/code&gt; or an &lt;code&gt;upstream&lt;/code&gt; block once, at start. We learned in September&lt;br /&gt;
what that costs: after a reboot race, nginx came up before DNS and the&lt;br /&gt;
whole public site stayed down for hours. So the proxies use&lt;br /&gt;
&lt;code&gt;resolver ... valid=10s&lt;/code&gt; and a variable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;location /forgejo/ {
    set $forgejo_backend &amp;quot;&amp;lt;internal name&amp;gt;:3000&amp;quot;;
    rewrite ^/forgejo/(.*)$ /$1 break;
    proxy_pass http://$forgejo_backend;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things we only found by testing it in throwaway nginx containers&lt;br /&gt;
against an echo backend, with URLs containing &lt;code&gt;%2F&lt;/code&gt;, spaces and query&lt;br /&gt;
strings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;rewrite ... break&lt;/code&gt; stops the rewrite module, and &lt;code&gt;set&lt;/code&gt; is part of it.&lt;br /&gt;
Put &lt;code&gt;set&lt;/code&gt; after the rewrite and the variable is empty: every request&lt;br /&gt;
returns 500.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;proxy_pass&lt;/code&gt; with a variable and a URI does not strip the location prefix&lt;br /&gt;
the way the static form does. The rewrite above reproduces the old&lt;br /&gt;
behaviour byte for byte; the raw-URI alternative we tried first passed&lt;br /&gt;
&lt;code&gt;%2F&lt;/code&gt; through undecoded, which differs from what Forgejo had always seen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The agents keep their identity.&lt;/strong&gt; The Forgejo CLI looks up its token by&lt;br /&gt;
host string. Adding entries for the new name the obvious way, with the&lt;br /&gt;
shared bootstrap token, would have made every agent act as the operator.&lt;br /&gt;
A small helper now copies each agent&apos;s own entry to the new name, and has&lt;br /&gt;
tests for exactly that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Then the clients.&lt;/strong&gt; Nine repositories got the same one-line kind of&lt;br /&gt;
change in their CI files and default URLs, each through review.&lt;/p&gt;
&lt;h2&gt;Rehearse on a copy&lt;/h2&gt;
&lt;p&gt;The blue slot is a new VM, same size, built by OpenTofu and configured by&lt;br /&gt;
the same Ansible role, with one difference while it holds a copy of live&lt;br /&gt;
data: webhook delivery is limited to loopback. The copy contains every&lt;br /&gt;
webhook the live server has, and a test push on blue must not start real&lt;br /&gt;
CI pipelines.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/blog/forgejo-blue-green/cutover.svg&quot; alt=&quot;The upgrade, step by step&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Copy while green keeps running.&lt;/strong&gt; A consistent database snapshot through&lt;br /&gt;
SQLite&apos;s online backup API, then the repositories and package data&lt;br /&gt;
streamed with tar. 4.3 GB in about two and a half minutes; the same&lt;br /&gt;
number of files on both sides.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start 16.0.5 on the 9.0.3 data.&lt;/strong&gt; We were prepared to step through the&lt;br /&gt;
intermediate major versions. We did not need to: 64 migrations ran in&lt;br /&gt;
36 seconds, with no errors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify the way people use it.&lt;/strong&gt; API calls with an agent&apos;s token, a&lt;br /&gt;
clone and a push over SSH (the branch appeared on blue and not on green),&lt;br /&gt;
a container registry pull, a webhook test delivery that blue refused, and&lt;br /&gt;
the web UI in a real browser with the public URL routed to blue. HTTP&lt;br /&gt;
status codes alone have fooled us before.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The new slot also surfaced three problems that only a fresh host shows: a&lt;br /&gt;
log handler that tried to &amp;quot;reload&amp;quot; journald (it cannot be reloaded), a host&lt;br /&gt;
firewall that the old server never had and that blocked the Forgejo port,&lt;br /&gt;
and Forgejo 16 rewriting its own config file on start, which made every&lt;br /&gt;
Ansible run restart it. All three are fixed in the roles, so the next fresh&lt;br /&gt;
host does not hit them.&lt;/p&gt;
&lt;h2&gt;What went wrong&lt;/h2&gt;
&lt;p&gt;Applying the by-name change to one app, we re-rendered its environment file&lt;br /&gt;
with &lt;code&gt;--limit&lt;/code&gt; set to the app host. The playbook&apos;s check that the database&lt;br /&gt;
password is set lived in a separate play for the control node, and &lt;code&gt;--limit&lt;/code&gt;&lt;br /&gt;
skipped it. The file was written with an empty password and the API was&lt;br /&gt;
down for about two minutes. The dry run had shown the change; we read it&lt;br /&gt;
through a filter that dropped any line containing &amp;quot;password&amp;quot;, to keep&lt;br /&gt;
secrets out of the transcript.&lt;/p&gt;
&lt;p&gt;Two lessons, both now written down: the guard belongs inside the role,&lt;br /&gt;
where &lt;code&gt;--limit&lt;/code&gt; cannot skip it; and when hiding secrets in a diff, &lt;strong&gt;mask&lt;br /&gt;
the values, never drop the lines&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Making drift visible&lt;/h2&gt;
&lt;p&gt;The root cause was not the old version, it was that nobody could see it.&lt;br /&gt;
Forgejo fell five months behind without anyone deciding it should, because&lt;br /&gt;
nothing was looking.&lt;/p&gt;
&lt;p&gt;So the change that matters most is not the upgrade, it is a check. Every&lt;br /&gt;
Monday a job compares, for each component we pin, the pinned version with&lt;br /&gt;
the latest stable upstream release and, where the service reports it, with&lt;br /&gt;
the version actually running, and posts the result on the fleet&apos;s&lt;br /&gt;
coordination bus. On its first run Forgejo was seven major versions behind.&lt;br /&gt;
On Monday 5 October it reported Forgejo as current.&lt;/p&gt;
&lt;p&gt;Forgejo was not the only one: the first run also found other components&lt;br /&gt;
behind, which nobody had been looking at either. We do not publish that&lt;br /&gt;
list. A list of which services run old versions is a map for an attacker,&lt;br /&gt;
and it is out of date the day after it is written.&lt;/p&gt;
&lt;p&gt;That is the lesson. An upgrade closes one gap; monitoring stops the next one&lt;br /&gt;
from growing in silence. &amp;quot;What is behind&amp;quot; should be a query, not a memory.&lt;/p&gt;
&lt;h2&gt;The cutover&lt;/h2&gt;
&lt;p&gt;With every client on the name and the rehearsal green, the cutover was the&lt;br /&gt;
short part. We did everything that could be done in advance first: the dry&lt;br /&gt;
runs of both playbooks, and the file with the server&apos;s secrets. Then:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stop green.&lt;/strong&gt; From here its data is final.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Copy everything again&lt;/strong&gt;, the same tar stream as in the rehearsal: 4.3 GB,&lt;br /&gt;
19,523 files, 197 seconds. The database checksum matched on both sides.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give blue green&apos;s SSH host keys.&lt;/strong&gt; Clients pin the server&apos;s key by name,&lt;br /&gt;
so to them the switch is invisible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start 16.0.5 and let it migrate&lt;/strong&gt;, then point the one DNS value at blue.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Forgejo was down for about six minutes. Then the full check set, not a&lt;br /&gt;
sample: the API by name and through the public proxy, an SSH clone and push,&lt;br /&gt;
the web UI in a real browser, a webhook that started a real CI pipeline,&lt;br /&gt;
image builds that pushed to the registry and deployed, the CI login and the&lt;br /&gt;
log shipping.&lt;/p&gt;
&lt;p&gt;Two things broke, and both were ours:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;We stopped green while a CI deploy was still pulling an image.&lt;/strong&gt; The&lt;br /&gt;
check that CI was idle and the stop went out together, instead of one&lt;br /&gt;
after the other. The old version kept serving; the deploy ran again&lt;br /&gt;
after the switch. The runbook now says: read the idle check, then stop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The script that logs CI into Forgejo&lt;/strong&gt; scraped a form field that&lt;br /&gt;
Forgejo 16 no longer has. One small fix, and a note in the runbook that&lt;br /&gt;
anything scraping a web page will break on a major upgrade.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Green stays stopped, untouched and pinned to the old version as the&lt;br /&gt;
rollback. At the next upgrade the slots swap roles: blue is live, green is&lt;br /&gt;
where the new version is rehearsed.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Why we run SSL Labs against every public service — including this site</title>
      <link>https://www.inio.no/blog/2026-09-27-tls-certificate-testing.html</link>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-09-27-tls-certificate-testing.html</guid>
      <description>
	&lt;p&gt;A certificate that renews correctly is not the same as a TLS setup that works. This site&apos;s certificate renews fine — and yet, until recently, no externally-run test had ever checked what a real visitor&apos;s browser actually gets.&lt;/p&gt;
&lt;p&gt;So when services go public around here, one of the first checklist items is an external scan: &lt;a href=&quot;https://www.ssllabs.com/ssltest/analyze.html?d=www.inio.no&quot;&gt;SSL Labs&apos; SSL Server Test&lt;/a&gt;. We run it, we read it, and we treat the result as the baseline for the service, not as a one-time favour to the certificate.&lt;/p&gt;
&lt;p&gt;I ran it against this site while writing this post. The result is &lt;strong&gt;A+&lt;/strong&gt;, but the interesting part is what the report says &lt;em&gt;about&lt;/em&gt; that result — and what a test like this does that a curl from inside the lab can&apos;t.&lt;/p&gt;
&lt;h2&gt;A certificate is not a configuration&lt;/h2&gt;
&lt;p&gt;Renewal tells you one thing: the certificate authority still issues you a signed certificate on schedule. It does not tell you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;whether the served chain is complete and trusted by every major client,&lt;/li&gt;
&lt;li&gt;whether a browser and a generic script arrive at the same endpoint,&lt;/li&gt;
&lt;li&gt;which protocol versions and cipher suites are actually negotiated,&lt;/li&gt;
&lt;li&gt;whether HSTS is present and set to a sane value,&lt;/li&gt;
&lt;li&gt;whether the hostname the visitor typed resolves to the same TLS endpoint the certificate names,&lt;/li&gt;
&lt;li&gt;or whether an old, weak endpoint is quietly left enabled alongside the good one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are &lt;em&gt;configuration&lt;/em&gt; questions, and renewal software does not answer them. A certificate can renew forever on a host where the configuration behind it is broken, partial, or serving yesterday&apos;s defaults.&lt;/p&gt;
&lt;h2&gt;Why an external test, from a third party&lt;/h2&gt;
&lt;p&gt;The temptation is to test from inside: you know the certificate name, you can reach the host, you can even script a handshake. The problem is that inside-looking checks tend to test what you already believe.&lt;/p&gt;
&lt;p&gt;An external scanner like SSL Labs sits outside your network, in roughly the position a real visitor does. It resolves the public name, connects over the public path, and grades what it finds — without any shortcut you get for being on the same LAN, in the same DNS zone, or already holding the private key. If your setup only works &amp;quot;from here&amp;quot;, an external test is where that shows up.&lt;/p&gt;
&lt;p&gt;It also tests the way clients actually test: through the browser&apos;s trust store, through the intermediate chain shipped by every major CA, through the HTTP stack that applies HSTS. It tests every IP address the name resolves to, and simulates handshakes from dozens of real clients, so your TLS answer has to be consistent across endpoints — not just correct on the machine next to the server.&lt;/p&gt;
&lt;h2&gt;What the report grades&lt;/h2&gt;
&lt;p&gt;SSL Labs walks through everything a real deployment must get right, in five sections:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Certificate:&lt;/strong&gt; chain, validity dates, signature algorithm, key size, revocation coverage (OCSP/CRL).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Protocol support:&lt;/strong&gt; which TLS versions are enabled and which are disabled. TLS 1.0/1.1 have no business being enabled on a public service in 2026.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key exchange:&lt;/strong&gt; whether forward secrecy is actually in force — not just advertised.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cipher strength:&lt;/strong&gt; per-protocol cipher selection, with the weak ones that should be disabled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Configuration notes and HSTS:&lt;/strong&gt; whether clients are instructed to skip plain HTTP, and whether that instruction is durable (&lt;code&gt;includeSubDomains&lt;/code&gt;, a sane &lt;code&gt;max-age&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For this site, the report found: a full and trusted chain, &lt;strong&gt;TLS 1.3&lt;/strong&gt; negotiated with a modern AEAD cipher (&lt;code&gt;TLS_AES_256_GCM_SHA384&lt;/code&gt;), no protocol downgrades, strong key exchange, and HSTS set to &lt;code&gt;max-age=31536000; includeSubDomains&lt;/code&gt;. That last line is what pushes an otherwise-good deployment from A to A+ on the report&apos;s scale.&lt;/p&gt;
&lt;h2&gt;Why it is on the launch checklist&lt;/h2&gt;
&lt;p&gt;We learned the hard way that &amp;quot;the page loads&amp;quot; and &amp;quot;the user connection is sound&amp;quot; are different claims. Every service we run publicly gets the same short list before it counts as live:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;TLS 1.3&lt;/strong&gt; enabled, TLS 1.0 and 1.1 disabled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HSTS&lt;/strong&gt; set with subdomains, so a visitor never silently falls back to plain HTTP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A complete, trusted chain&lt;/strong&gt; — no missing intermediates concatenated by hand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An external scan&lt;/strong&gt; against the final public hostname, with the grade and its notes read — not just screenshot.&lt;/li&gt;
&lt;li&gt;The certificate&apos;s &lt;strong&gt;renewal path is automated and monitored&lt;/strong&gt;, so the setup that passed today is still the setup that serves tomorrow.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The tie between 4 and 5 is deliberate. A scan is a point-in-time photograph; renewal automation is what keeps the photograph true. Neither replaces the other, and a checklist that has one without the other is a checklist with a hole in it.&lt;/p&gt;
&lt;h2&gt;The honest caveat&lt;/h2&gt;
&lt;p&gt;A grade is a snapshot, not a promise. An A+ today means every client positioned like the tester is well served &lt;em&gt;right now&lt;/em&gt;. It does not guarantee the configuration survives the next deploy, the next certificate rotation, or the next server rebuild — which is exactly why the scan is on the checklist &lt;em&gt;when evaluating services&lt;/em&gt;, and the automation is what runs &lt;em&gt;between&lt;/em&gt; evaluations.&lt;/p&gt;
&lt;p&gt;If you run a public service, the same URL pattern works for yours:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&quot;https://www.ssllabs.com/ssltest/analyze.html?d=www.inio.no&quot;&gt;https://www.ssllabs.com/ssltest/analyze.html?d=www.inio.no&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Run it, read the notes under the grade, and fix what it flags — that&apos;s the point of the exercise. A certificate is table stakes. A configuration that earns its grade is the actual service.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Multi lingual support in the Helix editor</title>
      <link>https://www.inio.no/blog/2026-09-18-multilingual-spelling-in-the-Helix-editor.html</link>
      <pubDate>Fri, 18 Sep 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-09-18-multilingual-spelling-in-the-Helix-editor.html</guid>
      <description>
	&lt;p&gt;Writing in Markdown without a spellchecker is risky—especially if you write in multiple languages or have a habit of typing fast and making typos.&lt;/p&gt;
&lt;p&gt;Here is a short, reproducible guide to setting up &lt;strong&gt;multilingual spell and grammar checking (English &amp;amp; Norwegian)&lt;/strong&gt; in the &lt;strong&gt;Helix editor (&lt;code&gt;hx&lt;/code&gt;)&lt;/strong&gt; using &lt;strong&gt;&lt;code&gt;mise&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Tech Stack&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://helix-editor.com/&quot;&gt;Helix&lt;/a&gt; (&lt;code&gt;hx&lt;/code&gt;)&lt;/strong&gt;: Fast, modal terminal editor with built-in LSP support.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://mise.jdx.dev/&quot;&gt;mise&lt;/a&gt;&lt;/strong&gt;: Runtime and tool version manager used to manage language servers cleanly without &lt;code&gt;sudo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ltex-ls&lt;/code&gt;&lt;/strong&gt;: LanguageTool-based language server providing spell and grammar checking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;marksman&lt;/code&gt;&lt;/strong&gt;: Markdown language server providing document symbols, links, and completion.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3&gt;Files Involved&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;mise.toml&lt;/code&gt; (or &lt;code&gt;~/.config/mise/config.toml&lt;/code&gt;): Installs and manages &lt;code&gt;ltex-ls&lt;/code&gt; and &lt;code&gt;marksman&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt;: Defines the language servers and attaches them to Markdown.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.helix/languages.toml&lt;/code&gt; &lt;em&gt;(optional)&lt;/em&gt;: Per-project override to switch between English and Norwegian.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3&gt;Step 1: Install the Language Servers with mise&lt;/h3&gt;
&lt;p&gt;Install &lt;code&gt;ltex-ls&lt;/code&gt; (GitHub backend) and &lt;code&gt;marksman&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Global mise setup:
mise use -g github:valentjn/ltex-ls@latest
mise use -g marksman@latest

# Or declare in your project&apos;s mise.toml:
# [tools]
# &amp;quot;github:valentjn/ltex-ls&amp;quot; = &amp;quot;latest&amp;quot;
# marksman = &amp;quot;latest&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip on PATH:&lt;/strong&gt; Ensure &lt;code&gt;~/.local/bin&lt;/code&gt; is in your &lt;code&gt;$PATH&lt;/code&gt;. If needed, symlink the binaries directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ln -s ~/.local/share/mise/installs/github-valentjn-ltex-ls/latest/bin/ltex-ls ~/.local/bin/ltex-ls
ln -s ~/.local/share/mise/installs/marksman/latest/marksman ~/.local/bin/marksman
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;Step 2: Configure Helix (&lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Edit or create &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;# 1. English spell checking (British English; use &amp;quot;en-US&amp;quot; for American)
[language-server.ltex-en]
command = &amp;quot;ltex-ls&amp;quot;

[language-server.ltex-en.config]
ltex.language = &amp;quot;en-GB&amp;quot;

# 2. Norwegian Bokmål spell checking
[language-server.ltex-nb]
command = &amp;quot;ltex-ls&amp;quot;

[language-server.ltex-nb.config]
ltex.language = &amp;quot;nb&amp;quot;

# 3. Attach marksman and ltex-en to Markdown
[[language]]
name = &amp;quot;markdown&amp;quot;
language-servers = [&amp;quot;marksman&amp;quot;, &amp;quot;ltex-en&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;Step 3: Switching Between English and Norwegian&lt;/h3&gt;
&lt;p&gt;Running both &lt;code&gt;ltex-en&lt;/code&gt; and &lt;code&gt;ltex-nb&lt;/code&gt; simultaneously is &lt;strong&gt;not&lt;/strong&gt; recommended because English text will be flagged as misspelled Norwegian and vice versa.&lt;/p&gt;
&lt;p&gt;Instead:&lt;br /&gt;
- Keep &lt;code&gt;ltex-en&lt;/code&gt; as your global default in &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt;.&lt;br /&gt;
- When working in a repository or directory that is primarily Norwegian, create a local &lt;code&gt;.helix/languages.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[[language]]
name = &amp;quot;markdown&amp;quot;
language-servers = [&amp;quot;marksman&amp;quot;, &amp;quot;ltex-nb&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Helix automatically merges project-level &lt;code&gt;.helix/languages.toml&lt;/code&gt; with your global configuration.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Step 4: Verify the Setup&lt;/h3&gt;
&lt;p&gt;Check that Helix recognizes the language servers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;hx --health markdown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see green checkmarks for both servers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Configured language servers:
  ✓ marksman: /home/inio/.local/bin/marksman
  ✓ ltex-en: /home/inio/.local/bin/ltex-ls
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;Summary&lt;/h3&gt;
&lt;p&gt;With &lt;code&gt;mise&lt;/code&gt; managing the language server binaries and Helix natively handling multiple language servers per file type, you get real-time Markdown intelligence and grammar/spell checking with zero bloat and seamless switching between languages.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Fetch data from Open Street Maps</title>
      <link>https://www.inio.no/blog/2026-09-12-fetch-data-from-osm.html</link>
      <pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-09-12-fetch-data-from-osm.html</guid>
      <description>
	&lt;p&gt;I need fake nodes in my grid to simulate data-points, providing dynamic data, I grabbed the nodes from OSM.  Here is the script i used to grab Norwegian fuel stations.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  import requestsimport pandas as pd
  query = &amp;quot;&amp;quot;&amp;quot;[out:json][timeout:180];
  area[&amp;quot;ISO3166-1&amp;quot;=&amp;quot;NO&amp;quot;][&amp;quot;boundary&amp;quot;=&amp;quot;administrative&amp;quot;]-&amp;gt;.norway;
  nwr[&amp;quot;amenity&amp;quot;=&amp;quot;fuel&amp;quot;](area.norway);
  out center tags;&amp;quot;&amp;quot;&amp;quot;
  response = requests.post(    &amp;quot;https://overpass-api.de/api/interpreter&amp;quot;,    data=query)response.raise_for_status()
  elements = response.json()[&amp;quot;elements&amp;quot;]
  rows = []
  for element in elements:    tags = element.get(&amp;quot;tags&amp;quot;, {})
      if element[&amp;quot;type&amp;quot;] == &amp;quot;node&amp;quot;:        latitude = element[&amp;quot;lat&amp;quot;]        longitude = element[&amp;quot;lon&amp;quot;]    else:        latitude = element.get(&amp;quot;center&amp;quot;, {}).get(&amp;quot;lat&amp;quot;)        longitude = element.get(&amp;quot;center&amp;quot;, {}).get(&amp;quot;lon&amp;quot;)
      rows.append({        &amp;quot;osm_id&amp;quot;: element[&amp;quot;id&amp;quot;],        &amp;quot;osm_type&amp;quot;: element[&amp;quot;type&amp;quot;],        &amp;quot;name&amp;quot;: tags.get(&amp;quot;name&amp;quot;),        &amp;quot;brand&amp;quot;: tags.get(&amp;quot;brand&amp;quot;),        &amp;quot;operator&amp;quot;: tags.get(&amp;quot;operator&amp;quot;),        &amp;quot;latitude&amp;quot;: latitude,        &amp;quot;longitude&amp;quot;: longitude,    })
  df = pd.DataFrame(rows)df.to_csv(&amp;quot;norway_fuel_stations.csv&amp;quot;, index=False)
  print(f&amp;quot;Saved {len(df)} stations&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;

      </description>
    </item>
    <item>
      <title>reMarkable syncbook</title>
      <link>https://www.inio.no/blog/2026-09-11-remarkable-sync.html</link>
      <pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-09-11-remarkable-sync.html</guid>
      <description>
	&lt;p&gt;Raw steps and data collected while building this, kept here so nothing gets forgotten while the real README.md gets written by hand. Not meant to be the&lt;br /&gt;
final doc&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Pull reMarkable tablet notebooks over SSH and render them to PNG/SVG; push a modified page back, safely (backup first, hash-verify after).&lt;/p&gt;
&lt;p&gt;No reMarkable cloud account, no desktop app, no USB required if your tablet is reachable over Wi-Fi/LAN -- just SSH.&lt;/p&gt;
&lt;h2&gt;Why&lt;/h2&gt;
&lt;p&gt;The reMarkable stores each notebook as a directory of pages in a reverse-engineered binary format (&amp;quot;lines v6&amp;quot;). &lt;code&gt;syncbook&lt;/code&gt; doesn&apos;t reimplement that format itself -- it shells out to the real &lt;code&gt;ssh&lt;/code&gt;/&lt;code&gt;scp&lt;/code&gt; for transport (so it inherits your normal host-key handling and &lt;code&gt;ssh-agent&lt;/code&gt;, nothing reimplemented) and to &lt;a href=&quot;https://github.com/ricklupton/rmc&quot;&gt;&lt;code&gt;rmc&lt;/code&gt;&lt;/a&gt; /&lt;br /&gt;
&lt;a href=&quot;https://cairosvg.org/&quot;&gt;&lt;code&gt;cairosvg&lt;/code&gt;&lt;/a&gt; via &lt;a href=&quot;https://astral.sh/uv&quot;&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/a&gt; for rendering. &lt;code&gt;syncbook&lt;/code&gt; itself is just the orchestration: find the notebook, move the right files, verify they arrived intact.&lt;/p&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A reMarkable tablet with SSH enabled (see below).&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://astral.sh/uv&quot;&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/a&gt; installed (&lt;code&gt;curl -LsSf https://astral.sh/uv/install.sh | sh&lt;/code&gt;)&lt;br /&gt;
-- used to run &lt;code&gt;rmc&lt;/code&gt;/&lt;code&gt;cairosvg&lt;/code&gt; without any manual &lt;code&gt;pip install&lt;/code&gt; step.&lt;/li&gt;
&lt;li&gt;Rust (to build &lt;code&gt;syncbook&lt;/code&gt; itself): &lt;code&gt;cargo build --release&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Enabling SSH on the reMarkable&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;On the tablet: &lt;strong&gt;Settings -&amp;gt; About -&amp;gt; General/Software&lt;/strong&gt;, tap the entry near the bottom that shows a serial number and a one-time root password. This also shows the tablet&apos;s IP address on your current network (or use &lt;code&gt;10.11.99.1&lt;/code&gt;, the fixed address reMarkable always assigns itself over USB).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;From your computer, do one interactive login with that password to confirm connectivity:&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh root@&amp;lt;tablet-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Install your public key so you never need the one-time password again:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat ~/.ssh/id_ed25519.pub | ssh root@&amp;lt;tablet-ip&amp;gt; \
  &apos;mkdir -p ~/.ssh &amp;amp;&amp;amp; chmod 700 ~/.ssh &amp;amp;&amp;amp; cat &amp;gt;&amp;gt; ~/.ssh/authorized_keys &amp;amp;&amp;amp; chmod 600 ~/.ssh/authorized_keys&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Generate a key first if you don&apos;t have one: &lt;code&gt;ssh-keygen -t ed25519&lt;/code&gt;.) Note the source file before the pipe and the &lt;code&gt;chmod&lt;/code&gt;s -- &lt;code&gt;sshd&lt;/code&gt; will silently ignore &lt;code&gt;authorized_keys&lt;/code&gt; if the permissions on &lt;code&gt;~/.ssh&lt;/code&gt; or the file itself are too open.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Confirm the key-based login works before moving on:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -o PreferredAuthentications=publickey root@&amp;lt;tablet-ip&amp;gt; echo ok
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Building&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cargo build --release
mkdir -p ~/bin
cp target/release/syncbook ~/bin/syncbook
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure &lt;code&gt;~/bin&lt;/code&gt; is on your &lt;code&gt;PATH&lt;/code&gt; (add &lt;code&gt;export PATH=&amp;quot;$HOME/bin:$PATH&amp;quot;&lt;/code&gt; to your shell profile if it isn&apos;t already).&lt;/p&gt;
&lt;h2&gt;First run&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;syncbook&lt;/code&gt; doesn&apos;t keep its own config file -- both &lt;code&gt;pullrm&lt;/code&gt; and &lt;code&gt;pushrm&lt;/code&gt; connect via a normal &lt;code&gt;~/.ssh/config&lt;/code&gt; host alias (default name: &lt;code&gt;remarkable&lt;/code&gt;), so plain &lt;code&gt;ssh remarkable&lt;/code&gt; / &lt;code&gt;scp remarkable:...&lt;/code&gt; on the command line behave identically to what &lt;code&gt;syncbook&lt;/code&gt; does internally.&lt;/p&gt;
&lt;p&gt;Run any command; if the alias isn&apos;t configured yet, you&apos;ll be prompted once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ syncbook pullrm &amp;quot;My Notebook&amp;quot;
No &apos;remarkable&apos; entry found in ~/.ssh/config -- first-time setup for syncbook.
reMarkable hostname or IP: 10.11.99.1
Path to SSH private key [/home/you/.ssh/id_ed25519]:
Wrote Host &apos;remarkable&apos; to ~/.ssh/config. Re-run your command to continue.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This appends a block like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# BEGIN SYNCBOOK MANAGED (remarkable)
Host remarkable
    HostName 10.11.99.1
    User root
    IdentityFile /home/you/.ssh/id_ed25519
    StrictHostKeyChecking accept-new
# END SYNCBOOK MANAGED (remarkable)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Edit that block directly (e.g. to change the IP after switching from USB to Wi-Fi) -- &lt;code&gt;syncbook&lt;/code&gt; only writes it once, on first run.&lt;/p&gt;
&lt;p&gt;If you already have a working SSH alias for your tablet under a different name, just pass &lt;code&gt;--host &amp;lt;name&amp;gt;&lt;/code&gt; instead of renaming anything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;syncbook --host my-existing-alias pullrm &amp;quot;My Notebook&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Usage&lt;/h2&gt;
&lt;p&gt;Pull a notebook by its visible name (as shown in the reMarkable UI) or by UUID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;syncbook pullrm &amp;quot;My Notebook&amp;quot;
# -&amp;gt; ./My_Notebook/&amp;lt;page-uuid&amp;gt;.rm, .svg, .png for each page, plus content.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Push a modified page back (backs up the current on-device state first, verifies the upload by hash afterward):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;syncbook pushrm &amp;quot;My Notebook&amp;quot; &amp;lt;page-uuid&amp;gt; ./modified-page.rm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: the reMarkable&apos;s own app (&lt;code&gt;xochitl&lt;/code&gt;) only re-parses a page from disk when its notebook is opened -- there is no live-reload signal to&lt;br /&gt;
send. After &lt;code&gt;pushrm&lt;/code&gt; succeeds, open (or close and reopen) the notebook on the tablet to see the change.&lt;/p&gt;
&lt;p&gt;Don&apos;t restart &lt;code&gt;xochitl&lt;/code&gt; itself (&lt;code&gt;systemctl restart xochitl&lt;/code&gt;) to &amp;quot;force&amp;quot; a refresh -- during development of&lt;br /&gt;
this tool that caused a spurious blank page to appear, most likely because the currently-open document&apos;s in-memory state and the file on disk briefly disagreed across the restart. Simply opening the notebook is both safer and sufficient.&lt;/p&gt;
&lt;h2&gt;Backups&lt;/h2&gt;
&lt;p&gt;Every &lt;code&gt;pushrm&lt;/code&gt; backs up the notebook&apos;s current &lt;code&gt;.metadata&lt;/code&gt;, &lt;code&gt;.content&lt;/code&gt;, and the target page&apos;s current &lt;code&gt;.rm&lt;/code&gt; (if it exists) to &lt;code&gt;~/remarkable-backups/&amp;lt;UTC-timestamp&amp;gt;/&amp;lt;notebook-uuid&amp;gt;/&lt;/code&gt; before touching anything on the device. Nothing is deleted automatically -- prune old backups by hand whenever you like.&lt;/p&gt;
&lt;h2&gt;Advanced: writing native ink annotations&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pushrm&lt;/code&gt; will happily upload any valid &lt;code&gt;.rm&lt;/code&gt; file, but &lt;em&gt;constructing&lt;/em&gt; one that adds new pen strokes to an existing page -- e.g. to draw a native ink&lt;br /&gt;
annotation on top of someone&apos;s sketch -- is real surgery on a reverse-engineered CRDT format, not something this tool automates. It&apos;s documented as a worked example instead of a polished subcommand, because getting it right requires understanding two non-obvious things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;New content should go on its own layer&lt;/strong&gt;, with its own freshly registered author ID -- see &lt;code&gt;examples/annotate.py&lt;/code&gt;, which uses &lt;a href=&quot;https://github.com/ricklupton/rmscene&quot;&gt;&lt;code&gt;rmscene&lt;/code&gt;&lt;/a&gt; to build a &lt;code&gt;Group&lt;/code&gt;, register it under the page&apos;s root, and add a &lt;code&gt;Line&lt;/code&gt; to it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A new CRDT sequence item must not fabricate a causal link to existing content.&lt;/strong&gt; The natural-seeming approach -- pointing your new layer&apos;s&lt;br /&gt;
&lt;code&gt;left_id&lt;/code&gt; at an existing item&apos;s ID, to insert it &amp;quot;after&amp;quot; that item -- gets silently accepted by &lt;code&gt;rmscene&lt;/code&gt;&apos;s reader (which is lenient) but &lt;strong&gt;rejected by the real reMarkable app&lt;/strong&gt; with &lt;code&gt;rm.crdt.item Item: invalid origin left: ... / rm.crdt.sequence - invalid item&lt;/code&gt; in &lt;code&gt;journalctl -u xochitl&lt;/code&gt;, because you never actually observed that item&apos;s insertion the way a real collaborating client would have. The correct, honest representation of an independently-added layer is &lt;code&gt;left_id = right_id = CrdtId(0, 0)&lt;/code&gt; -- a concurrent, uncoordinated insert, which is exactly what real independent edits are. Using a fabricated reference caused this exact failure during development, and the app&apos;s defensive recovery from it produced a stray blank page in the notebook as a side effect.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Run it like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python3 examples/annotate.py path/to/page.rm path/to/page-annotated.rm
syncbook pushrm &amp;quot;My Notebook&amp;quot; &amp;lt;page-uuid&amp;gt; path/to/page-annotated.rm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll need to adjust the circle&apos;s center/radius in the script to match where you actually want to annotate -- it&apos;s a worked example, not a general tool (finding a good general-purpose UI for &amp;quot;here&apos;s where to annotate&amp;quot; is future work, tracked as a shape/coordinate input problem, not a syncbook problem).&lt;/p&gt;
&lt;h2&gt;License&lt;/h2&gt;
&lt;p&gt;MIT licensed — see the LICENSE file in the syncbook repository.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Pulling charger data from NOBIL</title>
      <link>https://www.inio.no/blog/2026-09-11-pulling-charger-data.html</link>
      <pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-09-11-pulling-charger-data.html</guid>
      <description>
	&lt;p&gt;&lt;a href=&quot;https://info.nobil.no/&quot;&gt;NOBIL’s&lt;/a&gt; API provides data about public EV charging stations and charging points, primarily in Norway — available since October 2025. This is a great example of a public service.&lt;/p&gt;
&lt;p&gt;The available station data includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Station and charging-point IDs&lt;/li&gt;
&lt;li&gt;Name, address, municipality and country&lt;/li&gt;
&lt;li&gt;Latitude/longitude coordinates&lt;/li&gt;
&lt;li&gt;Operator and service-provider information&lt;/li&gt;
&lt;li&gt;Accessibility and opening hours&lt;/li&gt;
&lt;li&gt;Public/private access details&lt;/li&gt;
&lt;li&gt;Vehicle types supported&lt;/li&gt;
&lt;li&gt;Connector type—for example CCS, CHAdeMO, Type 2&lt;/li&gt;
&lt;li&gt;Number of connectors&lt;/li&gt;
&lt;li&gt;Charging capacity and power in kW&lt;/li&gt;
&lt;li&gt;Payment and access information&lt;/li&gt;
&lt;li&gt;Station descriptions, contact details and images&lt;/li&gt;
&lt;li&gt;Update or modification timestamps&lt;/li&gt;
&lt;li&gt;Real-time EVSE status where an operator supplies it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The real-time status values include AVAILABLE, CHARGING, BLOCKED, OUTOFORDER, RESERVED, INOPERATIVE, PLANNED, REMOVED, and UNKNOWN. Real-time data is supplied only for operators with an appropriate OCPI connection to NOBIL.&lt;/p&gt;
&lt;p&gt;You need to register for an API key before using the conventional API. The data is made available under a Creative Commons Attribution 4.0 licence, subject to NOBIL’s usage terms.&lt;/p&gt;
&lt;p&gt;How often can you pull it?&lt;/p&gt;
&lt;p&gt;The public API page and the currently indexed documentation do not state a fixed request limit such as “one request per minute” or “1,000 requests per day.” Therefore, you should not assume unlimited polling. Use caching, avoid repeatedly downloading the complete database, and ask NOBIL for the permitted rate if you need high-frequency or commercial polling.&lt;/p&gt;
&lt;p&gt;For normal station metadata, a sensible implementation would be:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Initial full import: once
Regular metadata refresh: every 15–60 minutes
Routine background synchronization: every few hours if minute-level changes are not important
Real-time availability: use NOBIL’s real-time WebSocket service instead of repeatedly polling the normal API
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NOBIL’s real-time service returns updates through a WebSocket stream, so you receive changes rather than continually requesting the full dataset. You subscribe to the real-time product, obtain a connection URL using an API key, and then connect using WebSockets.&lt;/p&gt;
&lt;p&gt;In practical terms: use the standard API for station details and configuration, and use the real-time stream for charger availability. Store the last response locally and only refresh data when necessary. Since NOBIL does not provide historical status data through the API, you would need to store successive real-time updates yourself if you want historical availability.&lt;/p&gt;
&lt;p&gt;Keep up working for the greater good.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Serial Console to a Nexus 5010</title>
      <link>https://www.inio.no/blog/2026-09-09-serial-console-to-a-nexus-5010.html</link>
      <pubDate>Wed, 9 Sep 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-09-09-serial-console-to-a-nexus-5010.html</guid>
      <description>
	&lt;h1&gt;Serial Console to a Nexus 5010&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TODO&lt;/strong&gt;: pull the Eth1/1 module — it&apos;s the one reporting invalid&lt;br /&gt;
calibration data below. Cheap enough to not be worth debugging further;&lt;br /&gt;
physically swap it out next time someone&apos;s at the rack.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The plan for the next phase of the fleet&apos;s network involves real managed&lt;br /&gt;
switching instead of the single unmanaged box currently sitting on the home&lt;br /&gt;
LAN. Step one of that plan arrived as a USB-to-serial adapter and a rollover&lt;br /&gt;
cable, reportedly plugged into the primary Proxmox host.&lt;/p&gt;
&lt;p&gt;It was not plugged into the primary Proxmox host.&lt;/p&gt;
&lt;h2&gt;Wrong server first&lt;/h2&gt;
&lt;p&gt;A quick sweep of the primary host — &lt;code&gt;lsusb&lt;/code&gt;, &lt;code&gt;dmesg&lt;/code&gt;, &lt;code&gt;journalctl -k&lt;/code&gt; since&lt;br /&gt;
boot, even a direct read of the built-in DB-9 COM port at 9600 8N1 — turned&lt;br /&gt;
up nothing. No serial chipset, no signal, silence. It has exactly one&lt;br /&gt;
physical serial port and five USB ports, all confirmed empty.&lt;/p&gt;
&lt;p&gt;The adapter was on the fleet&apos;s second (currently standalone) hypervisor,&lt;br /&gt;
instead. &lt;code&gt;lsusb&lt;/code&gt; found it immediately: a Prolific PL2303&lt;br /&gt;
USB-to-serial converter, cleanly bound to &lt;code&gt;/dev/ttyUSB0&lt;/code&gt;. A raw&lt;br /&gt;
&lt;code&gt;stty -F /dev/ttyUSB0 9600 cs8 -parenb -cstopb -echo raw&lt;/code&gt; followed by a&lt;br /&gt;
carriage return got an instant, live reply — the switch was already mid&lt;br /&gt;
first-boot, sitting in its setup dialog asking about password policy.&lt;/p&gt;
&lt;p&gt;Lesson, filed under the fleet&apos;s running &amp;quot;verify enforcement, not presence&amp;quot;&lt;br /&gt;
theme: a report that a cable is plugged into host A is a claim about host A,&lt;br /&gt;
not evidence about host A. Two live Proxmox hosts exist; check both before&lt;br /&gt;
concluding a physical link isn&apos;t there.&lt;/p&gt;
&lt;h2&gt;What showed up on the other end&lt;/h2&gt;
&lt;p&gt;The switch identifies itself, once logged in, as:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cisco Nexus Operating System (NX-OS) Software
Copyright (c) 2002-2010, Cisco Systems, Inc.
kickstart: version 4.2(1)N1(1)
system:    version 4.2(1)N1(1)
compile time: 4/29/2010
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A Nexus 5010 — fixed-config, twenty 10-Gigabit SFP+ ports, running a&lt;br /&gt;
software image compiled in April 2010. Old enough that a few of its&lt;br /&gt;
defaults needed working around, which turned out to be the most useful part&lt;br /&gt;
of the exercise.&lt;/p&gt;
&lt;h3&gt;First boot, deliberately incomplete&lt;/h3&gt;
&lt;p&gt;The Day-0 dialog on NX-OS asks a long string of yes/no questions before it&lt;br /&gt;
ever offers a prompt. Answered narrowly on purpose:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Secure password standard&lt;/strong&gt;: yes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;admin&lt;/code&gt; account password&lt;/strong&gt;: set, not written here — see the fleet&apos;s&lt;br /&gt;
usual secrets handling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A second login account&lt;/strong&gt;: no — NX-OS&apos;s &lt;code&gt;admin&lt;/code&gt; already is the&lt;br /&gt;
superuser-equivalent; nothing else was needed yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SNMP strings, NTP server, Fibre Channel, telnet, HTTP server&lt;/strong&gt;: all&lt;br /&gt;
declined. No community strings worth setting before there&apos;s a monitoring&lt;br /&gt;
target to point them at, and telnet/HTTP are just attack surface with SSH&lt;br /&gt;
already in the plan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Out-of-band &lt;code&gt;mgmt0&lt;/code&gt; IP and default gateway&lt;/strong&gt;: declined, deliberately.&lt;br /&gt;
The addressing plan for the new network segment doesn&apos;t exist yet — better&lt;br /&gt;
to leave the switch reachable only by console until it does, than guess an&lt;br /&gt;
IP that conflicts with whatever gets decided next.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH server&lt;/strong&gt;: enabled, with a freshly generated 2048-bit RSA host key&lt;br /&gt;
(RSA offered over the legacy DSA option).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The switch rebooted, applied the config, and came back with a clean&lt;br /&gt;
&lt;code&gt;switch#&lt;/code&gt; prompt.&lt;/p&gt;
&lt;h3&gt;A key type from the future&lt;/h3&gt;
&lt;p&gt;Adding the operator&apos;s SSH public key to the &lt;code&gt;admin&lt;/code&gt; account for later&lt;br /&gt;
passwordless access seemed like the obvious next step —&lt;br /&gt;
&lt;code&gt;username admin sshkey &amp;lt;key&amp;gt;&lt;/code&gt; is the NX-OS equivalent of dropping a line into&lt;br /&gt;
&lt;code&gt;authorized_keys&lt;/code&gt;. It rejected the key outright: &lt;code&gt;invalid SSH key format&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The key in question was Ed25519. Ed25519 support landed in OpenSSH in 2014.&lt;br /&gt;
This NX-OS image is from 2010. The command&apos;s own inline help (&lt;code&gt;username admin sshkey ?&lt;/code&gt;) doesn&apos;t enumerate accepted key types — it just says &lt;code&gt;LINE&lt;/code&gt;&lt;br /&gt;
— so the rejection reads identically whether the problem is a malformed&lt;br /&gt;
string or an unsupported algorithm entirely, which cost a few minutes&lt;br /&gt;
chasing a suspected serial-line corruption bug (a long pasted line &lt;em&gt;did&lt;/em&gt;&lt;br /&gt;
appear briefly garbled on the wire, a red herring caused by console&lt;br /&gt;
auto-wrap at the default 80-column terminal width — fixed with&lt;br /&gt;
&lt;code&gt;terminal width 511&lt;/code&gt;, and worth remembering for any future long paste over&lt;br /&gt;
this same console) before &lt;code&gt;show version&lt;/code&gt; settled it. An RSA key is needed&lt;br /&gt;
for this device; ed25519-only keys don&apos;t have anywhere to go here yet.&lt;/p&gt;
&lt;h2&gt;What&apos;s actually plugged in&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;show interface transceiver&lt;/code&gt; on a NX-OS box is the equivalent of walking&lt;br /&gt;
down the row of ports with a flashlight:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Ethernet1/1
    sfp is present but not supported
   SFP calibration is invalid
Ethernet1/2, 1/3, 1/5
    sfp is present
    type is Fabric Extender Transceiver
    part number is PLRXPL-VC-S43-CG
Ethernet1/4, 1/6-1/20
    sfp is not present
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two findings worth writing down before anyone reaches for these later:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Three of the four populated ports are FET optics&lt;/strong&gt; — Cisco&apos;s&lt;br /&gt;
Fabric-Extender-Transceiver SFP+, a cut-down 10G optic that only&lt;br /&gt;
establishes a link between a Nexus parent switch and a Nexus 2000-series&lt;br /&gt;
Fabric Extender. They will not link up to a server NIC, another switch, or&lt;br /&gt;
anything that isn&apos;t a FEX, no matter how good the fiber run is. If the plan&lt;br /&gt;
for this switch doesn&apos;t involve a 2000-series FEX downstream, these three&lt;br /&gt;
are the wrong optic for the job.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One SFP reports a hard failure&lt;/strong&gt;: present, but flagged unsupported with&lt;br /&gt;
invalid calibration data — the kind of thing a bad or counterfeit module&lt;br /&gt;
produces, not something a config command fixes. Worth physically swapping&lt;br /&gt;
before assuming any port-level troubleshooting will help.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All twenty fixed ports show &lt;code&gt;down&lt;/code&gt; in &lt;code&gt;show interface status&lt;/code&gt;, which is&lt;br /&gt;
expected — nothing is cabled to a live partner yet.&lt;/p&gt;
&lt;h2&gt;Next: Ansible, once there&apos;s an address to talk to&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;mgmt0&lt;/code&gt; is physically up (link negotiated at 1000 Mb/s) but carries no IP,&lt;br /&gt;
by design — bringing this switch under Ansible management is the next&lt;br /&gt;
phase, and it depends on the network addressing plan landing first. The&lt;br /&gt;
outline:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add the &lt;code&gt;cisco.nxos&lt;/code&gt; Ansible Galaxy collection (and its&lt;br /&gt;
&lt;code&gt;ansible.netcommon&lt;/code&gt; dependency) to the control node.&lt;/li&gt;
&lt;li&gt;A new inventory group in &lt;code&gt;inio-infrastructure&lt;/code&gt; for network switches, with&lt;br /&gt;
&lt;code&gt;ansible_connection=ansible.netcommon.network_cli&lt;/code&gt; and&lt;br /&gt;
&lt;code&gt;ansible_network_os=cisco.nxos.nxos&lt;/code&gt; — this NX-OS image predates NX-API,&lt;br /&gt;
so it&apos;s classic CLI-over-SSH, not the REST-style API newer Nexus gear&lt;br /&gt;
offers.&lt;/li&gt;
&lt;li&gt;Credentials in OpenBao, not the playbook — the password set today is a&lt;br /&gt;
known default and gets rotated once real management begins.&lt;/li&gt;
&lt;li&gt;A role covering the fleet&apos;s standing observability baseline (syslog to&lt;br /&gt;
OpenObserve, NTP) plus VLAN/interface config, none of which can be&lt;br /&gt;
written meaningfully until the addressing plan exists.&lt;/li&gt;
&lt;li&gt;The Cat6 (copper) SFPs that are still in transit will presumably replace&lt;br /&gt;
or supplement the FET optics currently occupying three of the four&lt;br /&gt;
populated ports, depending on what those links are meant to carry.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For now: password set, SSH enabled, transceivers inventoried, one bad optic&lt;br /&gt;
identified, and a decade-old assumption about key algorithms re-learned the&lt;br /&gt;
hard way. Console access remains the only way in until the network plan&lt;br /&gt;
says otherwise.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;HOLD&lt;/strong&gt; Ordered: Waiting for SFP.:&lt;/p&gt;
&lt;/blockquote&gt;

      </description>
    </item>
    <item>
      <title>IKEA VIDGA connector</title>
      <link>https://www.inio.no/blog/2026-04-29-ikea-vigda-connector.html</link>
      <pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-04-29-ikea-vigda-connector.html</guid>
      <description>
	&lt;p&gt;This is a connector for the rails used by the &lt;a href=&quot;https://www.ikea.com/no/en/p/vidga-corner-piece-single-track-included-ceiling-fitting-white-30498381/&quot;&gt;IKEA VIDGA&lt;/a&gt; curtain track systems. Personally, I find the connectors that came with VIDGA complicated and flimsy, especially when the hex bolts are already there.&lt;/p&gt;
&lt;p&gt;The connector repurposes the hex bolts from the end terminators.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../img/ikea/ikea-vidga-assembled.png&quot; alt=&quot;Assembled&quot; title=&quot;img/ikea/ikea-vidga-assembled.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The STL file is only the gray piece. Place the connector halfway in the rail, and fasten with the bolt; It should be a snug fit.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../img/ikea/ikea-vidga-connector.png&quot; alt=&quot;Connector&quot; title=&quot;ikea/ikea-vidga-connector.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Here you can download the connector STL file: &lt;a href=&quot;../img/ikea/ikea-vidga-connector.stl&quot;&gt;connector-STL-file&lt;/a&gt;&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>DNS Resolver configuration and setup</title>
      <link>https://www.inio.no/blog/2026-04-28-dns-resolvers.html</link>
      <pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-04-28-dns-resolvers.html</guid>
      <description>
	&lt;h2&gt;DNS in a Distributed Homelab: Control, Constraints, and What Actually Works&lt;/h2&gt;
&lt;p&gt;As internal services evolve and automation becomes more central, one challenge keeps resurfacing: DNS management across locations and sites.&lt;/p&gt;
&lt;p&gt;Keeping track of dynamically allocated IPs, ephemeral services, and changing infrastructure does not get easier over time — it becomes a source of increasing complexity.&lt;/p&gt;
&lt;p&gt;To address parts of this, I&apos;ve used and tested:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/sofagris/project-pegaprox&quot;&gt;Pegaprox&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://snipeitapp.com/&quot;&gt;SnipeIT&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These help structure inventory, but they don&apos;t fully solve the problem of how DNS should behave in a dynamic, distributed environment.&lt;/p&gt;
&lt;h2&gt;The Context: A Distributed Setup That Grew Organically&lt;/h2&gt;
&lt;p&gt;I run a shared homelab/data centre setup across multiple physical locations together with friends. As the system has grown, so has the complexity around:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../img/dns-inio-infrastructure.png&quot; alt=&quot;Image of DNS sites&quot; title=&quot;DNS infrastructure&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IP range management (dynamic public IPs and private ranges)&lt;/li&gt;
&lt;li&gt;Routing (VPN)&lt;/li&gt;
&lt;li&gt;DNS resolution (internal and external services)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Services move, scale, and get redeployed.&lt;/p&gt;
&lt;p&gt;Once you start down the rabbit hole, static assumptions break down quickly ;-)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../img/dns-resolver.png&quot; alt=&quot;DNS Setup and configuration&quot; title=&quot;DNS Resolvers&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Architecture: Simple, Distributed, and Actually Working&lt;/h2&gt;
&lt;p&gt;We&apos;ve converged on a solution that is both simple and robust — and, importantly, it works in practice.&lt;/p&gt;
&lt;p&gt;At the core is BIND, used to build a layered DNS structure.&lt;/p&gt;
&lt;h3&gt;Key principles&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Each location runs its own local DNS resolver.&lt;/li&gt;
&lt;li&gt;Resolvers point to secondary resolvers at other sites.&lt;/li&gt;
&lt;li&gt;Data and resolution capability are distributed across locations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each resolver then forwards queries to internal DNS servers that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Serve local IP ranges via internal zones.&lt;/li&gt;
&lt;li&gt;Expose services through both internal and external URLs.&lt;/li&gt;
&lt;li&gt;Enforce allow-lists for what can be resolved.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;DNS as a Control Plane&lt;/h2&gt;
&lt;p&gt;A central idea in this setup is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;DNS is not just a lookup service — it is a control layer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We log every request, and a &amp;quot;new request&amp;quot; will light up as a flare in the log rig.&lt;/p&gt;
&lt;p&gt;Instead of allowing unrestricted resolution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Unknown or non-approved queries are redirected to an internal &amp;quot;catch-all&amp;quot;.&lt;/li&gt;
&lt;li&gt;This catch-all acts as a controlled boundary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From there, a deliberate &amp;quot;break glass&amp;quot; mechanism is available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Authentication (username/password)&lt;/li&gt;
&lt;li&gt;Temporary &amp;quot;resolve-all&amp;quot; access (e.g. during installation)&lt;/li&gt;
&lt;li&gt;Or permanent allow-listing of specific domains&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This introduces a balance between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Security&lt;/li&gt;
&lt;li&gt;Operational control&lt;/li&gt;
&lt;li&gt;Flexibility when needed&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why This Matters&lt;/h2&gt;
&lt;p&gt;Letting everything resolve freely may feel simpler — but it quickly leads to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Loss of visibility&lt;/li&gt;
&lt;li&gt;Increased attack surface&lt;/li&gt;
&lt;li&gt;Harder debugging&lt;/li&gt;
&lt;li&gt;Unpredictable behaviour across environments&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By constraining DNS and making access explicit, the system becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;More predictable&lt;/li&gt;
&lt;li&gt;Easier to reason about&lt;/li&gt;
&lt;li&gt;Easier to operate over time&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Trade-off: Friction vs Control&lt;/h2&gt;
&lt;p&gt;Yes — this introduces friction.&lt;/p&gt;
&lt;p&gt;But it&apos;s intentional friction.&lt;/p&gt;
&lt;p&gt;Most of our systems have no need to resolve external services. Exceptions are mainly updates and short bursts of activity — &amp;quot;nothing that can&apos;t be solved using a cron job.&amp;quot;&lt;/p&gt;
&lt;p&gt;We trade a bit of convenience for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Traceability:&lt;/strong&gt; We log behaviour to &lt;a href=&quot;https://neo4j.com/&quot;&gt;neo4j&lt;/a&gt;,  and all new entries light up and are evaluated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reproducibility:&lt;/strong&gt; When we redeploy a system, we know what it is supposed to talk to, and can preconfigure this during the instrumentation process using Ansible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A clearer understanding of what our systems are actually doing.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Open Questions&lt;/h2&gt;
&lt;p&gt;Even though this setup works well, there are still open areas worth exploring:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We have still not reached a final plan for how to model inventory (IPs, services, DNS) consistently.&lt;/li&gt;
&lt;li&gt;Where is the right boundary between automation and manual control?&lt;/li&gt;
&lt;li&gt;Should DNS evolve into a policy engine — or stay a pure resolver?&lt;/li&gt;
&lt;li&gt;How much dynamism can DNS realistically support before abstraction is required?&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>Monoculture outside the standard</title>
      <link>https://www.inio.no/blog/2026-04-27-monokultur-utenfor-standard.html</link>
      <pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-04-27-monokultur-utenfor-standard.html</guid>
      <description>
	&lt;p&gt;Today I was called in to have a Teams meeting with a Norwegian government institution.&lt;/p&gt;
&lt;p&gt;Being what I am, a person of standards, not implementations ;-). I am inclined to use free and open-source software that adheres to standards.&lt;/p&gt;
&lt;p&gt;Logging into the meeting was easy peasy: select the camera (I have multiple); allow the browser/Teams URL to use my mic and camera; blur the background, before settling on a background that fit my personality.&lt;/p&gt;
&lt;p&gt;Only to watch myself making funny faces for five minutes.&lt;/p&gt;
&lt;p&gt;Great was my surprise when no faces showed up. The other participants could see me, but I was unable to see them.&lt;/p&gt;
&lt;p&gt;Ruffling around for five minutes to get things working... we rescheduled.&lt;/p&gt;
&lt;p&gt;There are some advantages to seeing each other when it comes to communication.&lt;/p&gt;
&lt;h2&gt;This is not a bug&lt;/h2&gt;
&lt;p&gt;What did happen?&lt;/p&gt;
&lt;p&gt;Sound was working, my image was showing, I could turn on blur, and switch between a number of virtual locations.&lt;/p&gt;
&lt;p&gt;When push came to shove, reality had a breakdown.&lt;/p&gt;
&lt;p&gt;Immediate danger? No.&lt;/p&gt;
&lt;p&gt;Long term? Yes.&lt;/p&gt;
&lt;p&gt;We see this in nature. When one species dominates too aggressively, it does not win — it destabilises the ecosystem. It consumes too much of the common resources, removes diversity and disables resilience, and inevitably it collapses.&lt;/p&gt;
&lt;p&gt;This is not philosophy; it is biology.&lt;/p&gt;
&lt;h2&gt;What just happened?&lt;/h2&gt;
&lt;p&gt;My setup worked:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The browser followed the spec.&lt;/li&gt;
&lt;li&gt;The camera delivered a stream.&lt;/li&gt;
&lt;li&gt;The browser rendered it locally.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But somewhere in the black box, &amp;quot;Teams&amp;quot; decided I was not — part of the team — and immediately digitally marginalised and digitally erased me.&lt;/p&gt;
&lt;p&gt;Not because I failed.&lt;/p&gt;
&lt;p&gt;But because I was not conforming to an unwritten, proprietary expectation.&lt;/p&gt;
&lt;p&gt;The Norwegian house implements Microsoft&apos;s vision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NOT&lt;/strong&gt; the standards and guidelines developed by the World Wide Web Consortium (W3C).&lt;/p&gt;
&lt;p&gt;A quick search, and a visit to the Microsoft Teams device test, confirmed my suspicion: Firefox does not work.&lt;/p&gt;
&lt;p&gt;When our government tools don&apos;t implement standards, they undermine independent evolution, interoperability and long-term digital content survival.&lt;/p&gt;
&lt;p&gt;And by this we are endangering our democratic resilience.&lt;/p&gt;
&lt;p&gt;Whether citizens can access public information should not depend on which proprietary technology they happen to use. As long as it adheres to standards.&lt;/p&gt;
&lt;p&gt;Working with web development in my early years, I recall the effort we faced with users coming from the dominant platform, MS Windows, and its bundled Internet Explorer.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;When government bodies use software that do not implement standards/standard formats.&lt;/p&gt;
&lt;h2&gt;The cure&lt;/h2&gt;
&lt;p&gt;DO NOT spend taxpayer money on solutions that force the public to use a set of vendors, adhere to standards (DIN EN ISO 216, DIN 5008, ISO 9001, ISO 14001, ISO/IEC 27001, ISO 45001, ISO 31000, ISO 8601, HTML/CSS, and WCAG 2.2.) they are there for our convinience and common good.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Markdown Stuff</title>
      <link>https://www.inio.no/blog/2026-03-26-markdown-stuff.html</link>
      <pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-26-markdown-stuff.html</guid>
      <description>
	&lt;p&gt;In my effort to automate and assemble all my note-taking efforts over the years, I&apos;m in the process of migrating to JBake as the new home.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;No change without pain.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In my old &lt;a href=&quot;https://jaspervdj.be/hakyll/&quot;&gt;Hakyll&lt;/a&gt; static compiler, I had invested a large amount of effort into special, nice adaptations. Now I pay the price.&lt;/p&gt;
&lt;h2&gt;Problems&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;[x] Tables don&apos;t render&lt;/li&gt;
&lt;li&gt;[x] Code blocks are broken and don&apos;t work&lt;/li&gt;
&lt;li&gt;[ ] d2lang preprocessing is broken. Probably move this to a separate pipeline and serve static content from there.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Now I have reconfigured the rendering engine to use (CommonMark)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Here is the spec: &lt;a href=&quot;https://spec.commonmark.org/0.31.2/&quot;&gt;CommonMark Spec...&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Find a linter to check all posts. I probably have to write this one.&lt;/li&gt;
&lt;li&gt;[x] Code a translator for the remaining systems and their respective files. VIP.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Code block&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;This is a code block with &lt;strong&gt;annotation&lt;/strong&gt;: bash&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;
This is a code, that is
bash

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;This is a &lt;strong&gt;code block&lt;/strong&gt; with NO annotation&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
This is a code block
!bash

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Table&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; this   &lt;/th&gt;&lt;th align=&quot;right&quot;&gt;    is &lt;/th&gt;&lt;th align=&quot;left&quot;&gt; a       &lt;/th&gt;&lt;th align=&quot;right&quot;&gt; header &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; center &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; right &lt;/td&gt;&lt;td align=&quot;left&quot;&gt; left    &lt;/td&gt;&lt;td align=&quot;right&quot;&gt;  right &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; senter &lt;/td&gt;&lt;td align=&quot;right&quot;&gt; høyre &lt;/td&gt;&lt;td align=&quot;left&quot;&gt; venstre &lt;/td&gt;&lt;td align=&quot;right&quot;&gt;  høyre &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Tegn&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;æ, ø, å&lt;/li&gt;
&lt;li&gt;Æ, Ø, Å&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>Competitive Analysis: Guestimate vs. the Market</title>
      <link>https://www.inio.no/blog/2026-03-12-competitive-analysis-gzjson.html</link>
      <pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-12-competitive-analysis-gzjson.html</guid>
      <description>
	&lt;h1&gt;Competitive Analysis: Where Does Guestimate Fit?&lt;/h1&gt;
&lt;h2&gt;The Market&lt;/h2&gt;
&lt;p&gt;The audience engagement and interactive quiz market is mature and well-funded. The dominant players are SaaS platforms built for education, corporate training, and event presentations. Interactive content sees 52.6% higher engagement than static content, with users spending 13 minutes on average vs. 8.5 minutes on static formats.&lt;/p&gt;
&lt;p&gt;None of the major players focus on &lt;strong&gt;estimation as a game mechanic&lt;/strong&gt;. This is our gap.&lt;/p&gt;
&lt;h2&gt;Competitor Overview&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Product &lt;/th&gt;&lt;th&gt; HQ &lt;/th&gt;&lt;th&gt; Focus &lt;/th&gt;&lt;th&gt; Participants &lt;/th&gt;&lt;th&gt; Price (entry) &lt;/th&gt;&lt;th&gt; Price (1000 users) &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Kahoot!&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Oslo &lt;/td&gt;&lt;td&gt; Game-based learning &lt;/td&gt;&lt;td&gt; 20–2000 &lt;/td&gt;&lt;td&gt; $17/mo &lt;/td&gt;&lt;td&gt; ~$75/mo (Pro) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Mentimeter&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Stockholm &lt;/td&gt;&lt;td&gt; Interactive presentations &lt;/td&gt;&lt;td&gt; Varies &lt;/td&gt;&lt;td&gt; €13/mo &lt;/td&gt;&lt;td&gt; Enterprise tier &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Slido&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Bratislava (Cisco) &lt;/td&gt;&lt;td&gt; Polls &amp;amp; Q&amp;amp;A for meetings &lt;/td&gt;&lt;td&gt; 100–5000 &lt;/td&gt;&lt;td&gt; Free/3 polls &lt;/td&gt;&lt;td&gt; $200/mo (Enterprise) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;AhaSlides&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Singapore &lt;/td&gt;&lt;td&gt; Interactive quizzes &lt;/td&gt;&lt;td&gt; 50+ &lt;/td&gt;&lt;td&gt; $7.95/mo &lt;/td&gt;&lt;td&gt; Custom &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Wooclap&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Brussels &lt;/td&gt;&lt;td&gt; Audience response (education) &lt;/td&gt;&lt;td&gt; Unlimited &lt;/td&gt;&lt;td&gt; $3.99/mo &lt;/td&gt;&lt;td&gt; Custom &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Guestimate&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Norway &lt;/td&gt;&lt;td&gt; Estimation game for venues &lt;/td&gt;&lt;td&gt; 40/game &lt;/td&gt;&lt;td&gt; Self-hosted &lt;/td&gt;&lt;td&gt; Self-hosted &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Feature Comparison&lt;/h2&gt;
&lt;h3&gt;What They All Do&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Multiple choice / polling questions&lt;/li&gt;
&lt;li&gt;Live leaderboards and results&lt;/li&gt;
&lt;li&gt;QR code join flow&lt;/li&gt;
&lt;li&gt;Mobile-first player experience&lt;/li&gt;
&lt;li&gt;Analytics and reporting&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;What They Don&apos;t Do&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Estimation-based gameplay&lt;/strong&gt; — all competitors use discrete answer formats (multiple choice, ranking, matching). None use continuous numerical estimation with range inputs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Venue-bound lobbies&lt;/strong&gt; — competitors are session-based (presenter creates, shares code). None bind games to physical locations for recurring, unattended play.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Facilitator-free operation&lt;/strong&gt; — all competitors require a presenter/host. Guestimate&apos;s common screen &lt;em&gt;is&lt;/em&gt; the host. The system runs itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dual-screen architecture&lt;/strong&gt; — competitors show results on the presenter&apos;s shared screen. Guestimate has a dedicated common screen (TV/projector) separate from player phones, designed for public spaces.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Competitor Deep Dive&lt;/h2&gt;
&lt;h3&gt;Kahoot! — The Market Leader&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Brand recognition, 9+ billion cumulative participants, deep integrations (Teams, Zoom, LMS). ISO/SOC2/GDPR compliant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weakness&lt;/strong&gt;: Requires a host. Quiz-only format (multiple choice). Pricing scales per host, not per participant. Not designed for unattended public spaces.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: $17–75/mo per host. One-time events from $250. Up to 2000 participants on Pro.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relevance&lt;/strong&gt;: Kahoot is the name people know. Any pitch must answer &amp;quot;why not just use Kahoot?&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Mentimeter — The Presentation Tool&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strength&lt;/strong&gt;: 100M+ users, 220 countries. Strong in corporate meetings and conferences. Word clouds, scales, open-ended questions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weakness&lt;/strong&gt;: Presentation-first, not game-first. No competitive scoring or leaderboard focus. Annual-only billing frustrates users. Free tier very limited.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: From €13/mo per presenter. Enterprise pricing unlisted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relevance&lt;/strong&gt;: Mentimeter is about gathering opinions, not playing games. Different use case, but overlaps in &amp;quot;engagement at events.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Slido — Enterprise Polls (Cisco)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Cisco backing. Native integrations with Webex, Teams, PowerPoint. Strong moderation and Q&amp;amp;A features.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weakness&lt;/strong&gt;: Meeting-oriented. Quiz is a secondary feature. Free tier limited to 3 polls per event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Free to $200/mo. One-time events from $49.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relevance&lt;/strong&gt;: Enterprise buying power. If a corporate customer already uses Webex, Slido comes bundled.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;AhaSlides — Budget Alternative&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strength&lt;/strong&gt;: Cheapest entry point ($7.95/mo). Team play mode. Self-paced quizzes. Good for ice-breakers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weakness&lt;/strong&gt;: Smaller market presence. Limited to standard quiz formats.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Free (5 questions, 50 participants) to custom enterprise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relevance&lt;/strong&gt;: Price competitor if we go SaaS. Shows the floor of what the market will pay.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Wooclap — Education Focus&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strength&lt;/strong&gt;: 21 question types. Free for K-12. Strong in European education market.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weakness&lt;/strong&gt;: Education-first, weak in entertainment/venue use cases.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: $3.99–7.99/mo per teacher.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relevance&lt;/strong&gt;: Shows education is a viable vertical, but not our primary target.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Guestimate&apos;s Differentiators&lt;/h2&gt;
&lt;h3&gt;1. Estimation as Game Mechanic&lt;/h3&gt;
&lt;p&gt;No competitor does this. Players submit numerical ranges, not multiple-choice answers. This creates a unique cognitive challenge: &amp;quot;How big is Norway?&amp;quot; requires genuine estimation, not recognition of a pre-written answer. The range-based scoring rewards both accuracy and confidence (narrow ranges score higher when correct).&lt;/p&gt;
&lt;h3&gt;2. Venue-Bound, Facilitator-Free&lt;/h3&gt;
&lt;p&gt;The system is designed for airports, pubs, and conference lobbies — places where a screen runs continuously without a human host. Players scan a QR code, play, and leave. The next game starts automatically. No competitor supports this model.&lt;/p&gt;
&lt;h3&gt;3. Location-Aware Content&lt;/h3&gt;
&lt;p&gt;Questions can be tagged to locations and audiences. A pub in Oslo gets Norwegian trivia. A tech conference gets industry estimation questions. This makes the content contextually relevant without manual curation per event.&lt;/p&gt;
&lt;h3&gt;4. Self-Hosted / On-Premise&lt;/h3&gt;
&lt;p&gt;Unlike all competitors (pure SaaS), Guestimate can run on-premise. For venues with privacy requirements or unreliable internet, this is a genuine advantage. It also means zero per-seat licensing — the venue pays once.&lt;/p&gt;
&lt;h2&gt;Competitive Positioning Matrix&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;                    Game-first ←————————————→ Tool-first
                         |                        |
   Unattended    Guestimate                       |
   (venue)             |                          |
        |              |                          |
        |         Kahoot!        AhaSlides        |
        |              |              |           |
   Hosted         _____|______________|___________|
   (presenter)         |              |           |
        |         Mentimeter    Wooclap     Slido |
        |              |              |           |
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Pricing Implications&lt;/h2&gt;
&lt;p&gt;The market shows clear pricing bands:&lt;br /&gt;
- &lt;strong&gt;Budget&lt;/strong&gt;: $4–8/mo (AhaSlides, Wooclap) — limited features&lt;br /&gt;
- &lt;strong&gt;Standard&lt;/strong&gt;: $13–20/mo (Kahoot, Mentimeter) — per host&lt;br /&gt;
- &lt;strong&gt;Professional&lt;/strong&gt;: $50–75/mo (Kahoot Pro, Slido Pro) — branding, analytics&lt;br /&gt;
- &lt;strong&gt;Enterprise&lt;/strong&gt;: $200+/mo (Slido, Mentimeter) — SSO, support, scale&lt;/p&gt;
&lt;p&gt;Guestimate&apos;s venue model doesn&apos;t fit per-host pricing. A &lt;strong&gt;per-venue monthly subscription&lt;/strong&gt; or &lt;strong&gt;per-event flat fee&lt;/strong&gt; would be more natural. Suggested tiers to explore:&lt;br /&gt;
- &lt;strong&gt;Free&lt;/strong&gt;: 1 venue, default questions, 40 players&lt;br /&gt;
- &lt;strong&gt;Pro&lt;/strong&gt;: Unlimited venues, custom questions, branding, analytics&lt;br /&gt;
- &lt;strong&gt;Enterprise&lt;/strong&gt;: On-premise, API access, white-label&lt;/p&gt;
&lt;h2&gt;Key Takeaway&lt;/h2&gt;
&lt;p&gt;Guestimate is not competing with Kahoot on features. It competes on &lt;em&gt;context&lt;/em&gt;: the unattended, location-bound, estimation-focused game that runs itself in public spaces. The answer to &amp;quot;why not Kahoot?&amp;quot; is: &lt;em&gt;because Kahoot needs a host, and you need a screen that runs 24/7 in your lobby.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The go-to-market should target venue operators (pubs, airports, conference centres) who want ambient entertainment — not trainers or presenters who want a quiz tool.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Design: Scaling to 1000 Concurrent Users</title>
      <link>https://www.inio.no/blog/2026-03-11-scaling-design-1000-users.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-scaling-design-1000-users.html</guid>
      <description>
	&lt;h1&gt;Multi-Game Scalability: The Path to 1000 Concurrent Users&lt;/h1&gt;
&lt;p&gt;To achieve our goal of 1000 concurrent players in the game engine, we must transition from a single-instance architecture to a distributed, horizontally scalable system.&lt;/p&gt;
&lt;h2&gt;1. Architectural Shift: From Synchronized to Distributed&lt;/h2&gt;
&lt;p&gt;The current engine relies on Java-level synchronization and embedded persistence. In a multi-node setup, this causes state fragmentation.&lt;/p&gt;
&lt;h3&gt;Key Changes:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Centralized Persistence&lt;/strong&gt;: Moving to a shared PostgreSQL or H2 Server instance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimistic Concurrency&lt;/strong&gt;: Implementing versioned updates in the &lt;code&gt;Game&lt;/code&gt; domain object to prevent race conditions during concurrent answer submissions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event Orchestration&lt;/strong&gt;: Introducing &lt;strong&gt;Redis&lt;/strong&gt; as an event bus to synchronize SSE updates across all nodes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. Distributed SSE Event Flow&lt;/h2&gt;
&lt;p&gt;Players connected to &lt;code&gt;Node A&lt;/code&gt; must see events triggered by players on &lt;code&gt;Node B&lt;/code&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Trigger&lt;/strong&gt;: An action occurs (e.g., &lt;code&gt;participate()&lt;/code&gt; on Node A).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish&lt;/strong&gt;: Node A publishes a &lt;code&gt;PLAYER_JOINED&lt;/code&gt; event to the Redis channel &lt;code&gt;game:&amp;lt;gameCode&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Broadcast&lt;/strong&gt;: All nodes (A, B, C...) subscribed to that channel receive the event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push&lt;/strong&gt;: Each node pushes the event to its locally connected SSE clients.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. Load Balancing &amp;amp; Reliability&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stickiness&lt;/strong&gt;: Use Nginx &lt;code&gt;ip_hash&lt;/code&gt; to keep game sessions localized where possible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-hydration&lt;/strong&gt;: Implement state recovery logic to allow the SMC state machine to resume from the database after a node restart.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. Measuring Success&lt;/h2&gt;
&lt;p&gt;We will track our progress toward the 1000-user target using OTel-exported metrics in &lt;strong&gt;OpenObserve&lt;/strong&gt;:&lt;br /&gt;
- &lt;code&gt;active_games_total&lt;/code&gt;&lt;br /&gt;
- &lt;code&gt;players_per_game_avg&lt;/code&gt;&lt;br /&gt;
- &lt;code&gt;event_propagation_latency_ms&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This design ensures that technical integrity remains high as we scale the product to its target audience.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Recognisable Play Specification</title>
      <link>https://www.inio.no/blog/2026-03-11-recognisable-play-design.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-recognisable-play-design.html</guid>
      <description>
	&lt;h1&gt;Recognisable Play&lt;/h1&gt;
&lt;p&gt;&lt;em&gt;A lightweight competitive game designed for trust, tension, and conversation.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;1. Core Intent&lt;/h2&gt;
&lt;p&gt;The game is designed to:&lt;br /&gt;
- Run &lt;strong&gt;fully unattended&lt;/strong&gt; if needed&lt;br /&gt;
- Create &lt;strong&gt;shared tension before and during play&lt;/strong&gt;&lt;br /&gt;
- Transition into a &lt;strong&gt;relaxed, social discussion&lt;/strong&gt; after the game&lt;br /&gt;
- Be &lt;strong&gt;easy to join&lt;/strong&gt;, hard to abuse, and safe to trust&lt;/p&gt;
&lt;p&gt;The facilitator is optional. The system itself is the primary host.&lt;/p&gt;
&lt;h2&gt;2. The Recognisable Pattern (Brand Feature)&lt;/h2&gt;
&lt;p&gt;Every game follows the same emotional and temporal rhythm:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Invite&lt;/strong&gt; – Common screen shows QR code, join URL, game code (AUTO_LOBBY)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wait&lt;/strong&gt; – 5-min lobby timer, +30s per new player when &amp;lt;30s remain&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Commit&lt;/strong&gt; – Timer expires or 40 players → game starts (COUNTDOWN)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tension&lt;/strong&gt; – Timed questions, visible response pressure (PLAYING → REVIEWING, repeats)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Release&lt;/strong&gt; – Results, fun facts, discussion (RESULTS)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reset&lt;/strong&gt; – Brief pause, then new game auto-creates (COOLDOWN → AUTO_LOBBY)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This pattern never changes. The cycle repeats indefinitely.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Players learn to &lt;em&gt;trust the rhythm&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is a &lt;strong&gt;brand feature&lt;/strong&gt;: when the counter appears, everyone knows what happens next.&lt;/p&gt;
&lt;h2&gt;3. UX Consistency Rules&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;One global countdown at a time&lt;/li&gt;
&lt;li&gt;One authoritative screen state&lt;/li&gt;
&lt;li&gt;Same transitions every game&lt;/li&gt;
&lt;li&gt;No surprise pauses or hidden waits&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Timers are sacred. If time runs out, the system acts.&lt;/p&gt;
&lt;h2&gt;4. Player Trust Model&lt;/h2&gt;
&lt;p&gt;Trust is built by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Transparency&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Scoring model is open&lt;/li&gt;
&lt;li&gt;Statistics are shared&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Privacy where it matters&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Scores are private&lt;/li&gt;
&lt;li&gt;Response-time pressure is public&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Predictability&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Same rules every game&lt;/li&gt;
&lt;li&gt;Same timers, same outcomes&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. Identity &amp;amp; Sessions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Identity is &lt;strong&gt;session-based&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Sticky via cookie/session storage&lt;/li&gt;
&lt;li&gt;Email is optional&lt;/li&gt;
&lt;li&gt;Email may be attached to session summary&lt;/li&gt;
&lt;li&gt;Late joiners:&lt;/li&gt;
&lt;li&gt;Attach to running game&lt;/li&gt;
&lt;li&gt;Cannot score on completed questions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No permanent accounts required.&lt;/p&gt;
&lt;h2&gt;6. Automation First&lt;/h2&gt;
&lt;p&gt;There is no facilitator. The system is the host.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Root URL visit creates a new game automatically&lt;/li&gt;
&lt;li&gt;Lobby timer starts on first player join (5 min)&lt;/li&gt;
&lt;li&gt;Game auto-starts, auto-advances, auto-restarts&lt;/li&gt;
&lt;li&gt;Player disconnects:&lt;/li&gt;
&lt;li&gt;Auto-answer with defaults&lt;/li&gt;
&lt;li&gt;After two auto-answers, stop waiting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The game never blocks indefinitely.&lt;/p&gt;
&lt;h2&gt;7. Competitive, but Feelgood&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Game is &lt;strong&gt;individual &amp;amp; competitive&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Goal is not winning — it is &lt;strong&gt;engagement and reflection&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Time pressure prevents overthinking&lt;/li&gt;
&lt;li&gt;Discussion after the game is part of the experience&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Winning feels good.&lt;br /&gt;
Learning feels better.&lt;/p&gt;
&lt;h2&gt;8. Technical Design Guidelines&lt;/h2&gt;
&lt;p&gt;For developers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single authoritative game engine&lt;/li&gt;
&lt;li&gt;Event-driven state transitions&lt;/li&gt;
&lt;li&gt;Idempotent joins&lt;/li&gt;
&lt;li&gt;Deterministic timers&lt;/li&gt;
&lt;li&gt;Explicit game phases (state machine):&lt;/li&gt;
&lt;li&gt;AUTO_LOBBY → COUNTDOWN → PLAYING → REVIEWING → RESULTS → COOLDOWN → AUTO_LOBBY&lt;/li&gt;
&lt;li&gt;FINISHED (terminal, via Halt only)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No hidden states.&lt;/p&gt;
&lt;h2&gt;9. Non-goals&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No long-term player ranking&lt;/li&gt;
&lt;li&gt;No monetisation hooks&lt;/li&gt;
&lt;li&gt;No dark patterns&lt;/li&gt;
&lt;li&gt;No artificial delays&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10. Summary&lt;/h2&gt;
&lt;p&gt;This game is not about complexity.&lt;/p&gt;
&lt;p&gt;It is about:&lt;br /&gt;
- Rhythm&lt;br /&gt;
- Trust&lt;br /&gt;
- Tension&lt;br /&gt;
- Release&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recognisable Play&lt;/strong&gt; is the product.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>The Finished Phase: Credits-Style Results Without Data Exposure</title>
      <link>https://www.inio.no/blog/2026-03-11-finished-phase-specification.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-finished-phase-specification.html</guid>
      <description>
	&lt;h1&gt;FINISHED Phase — Functional Specification&lt;/h1&gt;
&lt;h2&gt;1. Purpose of FINISHED&lt;/h2&gt;
&lt;p&gt;The FINISHED phase exists to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Provide closure to the game session&lt;/li&gt;
&lt;li&gt;Present final results in a shared, passive format&lt;/li&gt;
&lt;li&gt;Highlight collective and individual achievements without exposing personal data&lt;/li&gt;
&lt;li&gt;Define clear conditions for session termination&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The FINISHED phase is &lt;strong&gt;read-only&lt;/strong&gt;, &lt;strong&gt;non-interactive&lt;/strong&gt;, and &lt;strong&gt;time-bounded&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;2. Scope &amp;amp; Non-Goals&lt;/h2&gt;
&lt;h3&gt;In scope&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Common-screen presentation (credits-style)&lt;/li&gt;
&lt;li&gt;Final player ordering and display&lt;/li&gt;
&lt;li&gt;Presentation of derived statistical facts&lt;/li&gt;
&lt;li&gt;Session termination handling&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Explicitly out of scope&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Score calculation or modification&lt;/li&gt;
&lt;li&gt;Player interaction beyond session exit&lt;/li&gt;
&lt;li&gt;New game initialization&lt;/li&gt;
&lt;li&gt;Personal or identifying data exposure&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. Core Concepts (Mental Model)&lt;/h2&gt;
&lt;h3&gt;Final Credits Sequence (FCS)&lt;/h3&gt;
&lt;p&gt;The FINISHED phase is represented as a &lt;strong&gt;Final Credits Sequence&lt;/strong&gt;, similar to movie credits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Automatically scrolling&lt;/li&gt;
&lt;li&gt;Same content and order for all players&lt;/li&gt;
&lt;li&gt;Passive observation only&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The sequence may loop until the session terminates.&lt;/p&gt;
&lt;h2&gt;4. Data Responsibilities&lt;/h2&gt;
&lt;h3&gt;4.1 Final Player Results (Immutable)&lt;/h3&gt;
&lt;p&gt;Each player has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Final score&lt;/li&gt;
&lt;li&gt;Rank&lt;/li&gt;
&lt;li&gt;Accuracy metrics&lt;/li&gt;
&lt;li&gt;Timing metrics (aggregated)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Data is finalized before entering FINISHED&lt;/li&gt;
&lt;li&gt;No values may change during FINISHED&lt;/li&gt;
&lt;li&gt;No new data may be added&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4.2 Derived Statistics Dataset&lt;/h3&gt;
&lt;p&gt;Derived from game-wide data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Answer correctness&lt;/li&gt;
&lt;li&gt;Answer timing&lt;/li&gt;
&lt;li&gt;Answer deltas (distance from correct answer)&lt;/li&gt;
&lt;li&gt;Consistency metrics&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This dataset is input to the FINISHED statistics projection.&lt;/p&gt;
&lt;h2&gt;5. Statistics &amp;amp; Fact Projection (FINISHED)&lt;/h2&gt;
&lt;h3&gt;Purpose&lt;/h3&gt;
&lt;p&gt;To generate &lt;strong&gt;highlight-style statistical facts&lt;/strong&gt; that summarize notable outcomes of the game.&lt;/p&gt;
&lt;h3&gt;Characteristics&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Deterministic given the same input&lt;/li&gt;
&lt;li&gt;Non-personal&lt;/li&gt;
&lt;li&gt;Explainable&lt;/li&gt;
&lt;li&gt;Generated once and frozen&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Example Fact Categories&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Winner (highest score)&lt;/li&gt;
&lt;li&gt;Fastest to answer&lt;/li&gt;
&lt;li&gt;Slowest to answer&lt;/li&gt;
&lt;li&gt;Most correct answers&lt;/li&gt;
&lt;li&gt;Smallest average delta&lt;/li&gt;
&lt;li&gt;Most consistent answers&lt;/li&gt;
&lt;li&gt;Largest improvement over time&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. Presentation Rules&lt;/h2&gt;
&lt;h3&gt;Ordering&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Winner always displayed at the top&lt;/li&gt;
&lt;li&gt;Remaining players ordered by descending score&lt;/li&gt;
&lt;li&gt;Ties resolved deterministically&lt;/li&gt;
&lt;li&gt;Ordering must be stable across all clients&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Display&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Credits-style vertical scroll&lt;/li&gt;
&lt;li&gt;Shared pacing across all clients&lt;/li&gt;
&lt;li&gt;No user-controlled navigation&lt;/li&gt;
&lt;li&gt;Optional looping until session termination&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;7. FINISHED Flow Control&lt;/h2&gt;
&lt;h3&gt;Entry Conditions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;REVIEWING phase has completed&lt;/li&gt;
&lt;li&gt;All question reviews are finalized&lt;/li&gt;
&lt;li&gt;Final scores and statistics are available&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Session Termination Conditions&lt;/h3&gt;
&lt;p&gt;The FINISHED phase ends when &lt;strong&gt;any&lt;/strong&gt; of the following occurs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A player console stops pinging the server beyond a defined timeout&lt;/li&gt;
&lt;li&gt;A player explicitly selects “Join New Game”&lt;/li&gt;
&lt;li&gt;A server-defined idle timeout is reached&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Exit Behavior&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Session is marked as terminated&lt;/li&gt;
&lt;li&gt;No further events are accepted&lt;/li&gt;
&lt;li&gt;Resources may be released&lt;/li&gt;
&lt;li&gt;FINISHED data becomes archival&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. State Transitions&lt;/h2&gt;
&lt;h3&gt;REVIEWING → FINISHED&lt;/h3&gt;
&lt;p&gt;Occurs when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Final question review completes&lt;/li&gt;
&lt;li&gt;REVIEWING exit criteria are met&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;FINISHED → SESSION_TERMINATED&lt;/h3&gt;
&lt;p&gt;Occurs when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Any termination condition is triggered&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;9. Invariants (Must Always Hold)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No score changes during FINISHED&lt;/li&gt;
&lt;li&gt;No personal or identifying data is displayed&lt;/li&gt;
&lt;li&gt;All players see the same credits sequence&lt;/li&gt;
&lt;li&gt;Facts do not change once generated&lt;/li&gt;
&lt;li&gt;FINISHED contains no interactive gameplay elements&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10. Test Strategy&lt;/h2&gt;
&lt;h3&gt;10.1 Static Validation Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Final results exist for all players&lt;/li&gt;
&lt;li&gt;All statistics are computable&lt;/li&gt;
&lt;li&gt;Fact projection produces valid output&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.2 Presentation Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Correct ordering of players&lt;/li&gt;
&lt;li&gt;Winner always displayed first&lt;/li&gt;
&lt;li&gt;Stable ordering across clients&lt;/li&gt;
&lt;li&gt;Scroll behavior matches specification&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.3 Statistics Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Each fact is derivable from stored data&lt;/li&gt;
&lt;li&gt;No fact references raw or personal data&lt;/li&gt;
&lt;li&gt;Facts remain constant during FINISHED&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.4 Termination Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Session ends on ping timeout&lt;/li&gt;
&lt;li&gt;Session ends on join-new-game event&lt;/li&gt;
&lt;li&gt;Session ends on idle timeout&lt;/li&gt;
&lt;li&gt;No events accepted after termination&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;11. Acceptance Criteria&lt;/h2&gt;
&lt;p&gt;A player can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clearly see who won&lt;/li&gt;
&lt;li&gt;Understand how players performed collectively&lt;/li&gt;
&lt;li&gt;Observe interesting outcomes of the game&lt;/li&gt;
&lt;li&gt;Exit the session naturally&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Without:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Being able to infer personal or sensitive information&lt;/li&gt;
&lt;li&gt;Affecting game state&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>Scale-Estimation Slider: Player GUI Design</title>
      <link>https://www.inio.no/blog/2026-03-11-scale-estimation-slider-design.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-scale-estimation-slider-design.html</guid>
      <description>
	&lt;h2&gt;1. Concept: Scale-Only Estimation Input&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;The game is about &lt;strong&gt;estimating scale&lt;/strong&gt;, not selecting correct answers.&lt;br /&gt;
Users should express &lt;strong&gt;order of magnitude first&lt;/strong&gt;, then optionally refine their estimate.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There are &lt;strong&gt;no discrete choices&lt;/strong&gt; and &lt;strong&gt;no predefined steps&lt;/strong&gt;.&lt;br /&gt;
Input is continuous and relative.&lt;/p&gt;
&lt;h2&gt;2. Primary Input: Horizontal Scale Slider&lt;/h2&gt;
&lt;h3&gt;Base Interaction&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;horizontal slider&lt;/strong&gt; represents a &lt;strong&gt;logarithmic scale&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Left = very small&lt;/li&gt;
&lt;li&gt;Right = very large&lt;/li&gt;
&lt;li&gt;The exact numeric value is &lt;strong&gt;hidden initially&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Only the &lt;strong&gt;unit&lt;/strong&gt; is shown (e.g. &lt;code&gt;kg&lt;/code&gt;, &lt;code&gt;liters&lt;/code&gt;, &lt;code&gt;km&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;|-----------------------------|
 small                     large
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The slider is designed for &lt;strong&gt;coarse estimation&lt;/strong&gt;, not precision.&lt;/p&gt;
&lt;h2&gt;3. Progressive Precision via Vertical Gesture&lt;/h2&gt;
&lt;h3&gt;Refinement Gesture&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;While touching the slider:&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Horizontal movement&lt;/strong&gt; → changes magnitude&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vertical movement (upwards)&lt;/strong&gt; → increases precision&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vertical movement (downwards)&lt;/strong&gt; → decreases precision&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This allows &lt;strong&gt;coarse → fine adjustment&lt;/strong&gt; in a single continuous gesture.&lt;/p&gt;
&lt;h3&gt;Precision Levels&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Vertical Distance &lt;/th&gt;&lt;th&gt; Precision Mode &lt;/th&gt;&lt;th&gt; Behavior                           &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; Near slider       &lt;/td&gt;&lt;td&gt; Coarse         &lt;/td&gt;&lt;td&gt; Log scale, large jumps             &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Medium height     &lt;/td&gt;&lt;td&gt; Fine           &lt;/td&gt;&lt;td&gt; Linear interpolation               &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; High above        &lt;/td&gt;&lt;td&gt; Precision      &lt;/td&gt;&lt;td&gt; Decimal precision (max 2 decimals) &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Precision is capped at &lt;strong&gt;two decimal places&lt;/strong&gt;.&lt;br /&gt;
This prevents false accuracy.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;4. Scale Behavior (Important for Developers)&lt;/h2&gt;
&lt;h3&gt;Scale Type&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Default: &lt;strong&gt;logarithmic scale&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Purpose: enable navigation from e.g. &lt;code&gt;1&lt;/code&gt; → &lt;code&gt;1,000,000,000&lt;/code&gt; in one motion&lt;/li&gt;
&lt;li&gt;When precision increases:&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The scale &lt;strong&gt;locally transitions to linear&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Only within the current magnitude window&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This avoids:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;tiny finger movements jumping billions&lt;/li&gt;
&lt;li&gt;meaningless exactness at global scale&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;5. Visual Feedback&lt;/h2&gt;
&lt;h3&gt;While Sliding&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Show:&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Approximate value&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unit&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Optional scientific notation at coarse level&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;≈ 10⁶ kg
≈ 1 million kg
≈ 1.23 million kg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Formatting adapts to precision level automatically.&lt;/p&gt;
&lt;h2&gt;6. Input Rules (Explicit Constraints)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No keyboard input&lt;/li&gt;
&lt;li&gt;No direct number entry&lt;/li&gt;
&lt;li&gt;No snapping to predefined values&lt;/li&gt;
&lt;li&gt;No minimum / maximum shown numerically&lt;/li&gt;
&lt;li&gt;All values are &lt;strong&gt;soft-bounded&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;User can overshoot “reasonable” ranges&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Game logic evaluates plausibility, not validity&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;7. Completion Interaction&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;User releases finger → value is locked&lt;/li&gt;
&lt;li&gt;A short confirmation animation reinforces:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;gt; “This is your estimate”&lt;/p&gt;
&lt;p&gt;Only &lt;strong&gt;after submission&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The real value is revealed&lt;/li&gt;
&lt;li&gt;Comparison is shown visually (not numerically first)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. Example Developer-Facing Spec (Condensed)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;InputComponent: ScaleSlider

- Axis: Horizontal (logarithmic)
- Gesture:
  - X-axis: magnitude selection
  - Y-axis: precision modulation
- Precision:
  - Dynamic
  - Max 2 decimal places
- Units:
  - Always visible
- Values:
  - Continuous
  - No discrete steps
- Purpose:
  - Express scale intuition, not exact knowledge
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;9. Design Philosophy (One-liner)&lt;/h2&gt;
&lt;p&gt;“The user should feel like they are &lt;em&gt;feeling out the size of the world&lt;/em&gt;, not entering a number.”&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Import Data Structure</title>
      <link>https://www.inio.no/blog/2026-03-11-question-import-data-structure.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-question-import-data-structure.html</guid>
      <description>
	&lt;h1&gt;Import data structure&lt;/h1&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;id&amp;quot;: &amp;quot;UUID&amp;quot;,
  &amp;quot;questionText&amp;quot;: &amp;quot;String&amp;quot;,
  &amp;quot;isoUnit&amp;quot;: &amp;quot;String&amp;quot;,
  &amp;quot;defaultMin&amp;quot;: &amp;quot;int&amp;quot;,
  &amp;quot;defaultMax&amp;quot;: &amp;quot;int&amp;quot;,
  &amp;quot;correctValue&amp;quot;: &amp;quot;int&amp;quot;,
  &amp;quot;fasitSource&amp;quot;: &amp;quot;String (validated URL)&amp;quot;,
  &amp;quot;fasitCheckedAt&amp;quot;: &amp;quot;YYYY-MM-DD&amp;quot;,
  &amp;quot;illustrationUrl&amp;quot;: &amp;quot;String (PNG URL)&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Sample data&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[
  {
    &amp;quot;id&amp;quot;: &amp;quot;6c9f1f0e-1b2d-4a73-bb3a-001001001002&amp;quot;,
    &amp;quot;questionText&amp;quot;: &amp;quot;How long is the border between Norway and Russia?&amp;quot;,
    &amp;quot;isoUnit&amp;quot;: &amp;quot;km&amp;quot;,
    &amp;quot;defaultMin&amp;quot;: 100,
    &amp;quot;defaultMax&amp;quot;: 300,
    &amp;quot;correctValue&amp;quot;: 196,
    &amp;quot;fasitSource&amp;quot;: &amp;quot;https://en.wikipedia.org/wiki/Norway%E2%80%93Russia_border&amp;quot;,
    &amp;quot;fasitCheckedAt&amp;quot;: &amp;quot;2024-10-01&amp;quot;,
    &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://via.placeholder.com/512x512.png?text=How%20long%20is%20the%20border%20between%20Norway%20and%20Russia%3F&amp;quot;
  },
  {
    &amp;quot;id&amp;quot;: &amp;quot;6c9f1f0e-1b2d-4a73-bb3a-001001001006&amp;quot;,
    &amp;quot;questionText&amp;quot;: &amp;quot;How many U.S. presidents served before Ronald Reagan?&amp;quot;,
    &amp;quot;isoUnit&amp;quot;: &amp;quot;presidents&amp;quot;,
    &amp;quot;defaultMin&amp;quot;: 20,
    &amp;quot;defaultMax&amp;quot;: 50,
    &amp;quot;correctValue&amp;quot;: 39,
    &amp;quot;fasitSource&amp;quot;: &amp;quot;https://en.wikipedia.org/wiki/List_of_presidents_of_the_United_States&amp;quot;,
    &amp;quot;fasitCheckedAt&amp;quot;: &amp;quot;2024-10-01&amp;quot;,
    &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://via.placeholder.com/512x512.png?text=How%20many%20U.S.%20presidents%20served%20before%20Ronald%20Reagan%3F&amp;quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;

      </description>
    </item>
    <item>
      <title>Public Common Screen Flow</title>
      <link>https://www.inio.no/blog/2026-03-11-facilitator-free-common-screen-flow.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-facilitator-free-common-screen-flow.html</guid>
      <description>
	&lt;h1&gt;Public Common Screen Flow&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;Replaces the facilitator-driven game start. The common screen IS the game host.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;When a user points their browser at the root URL, a new game session is created automatically. The common screen displays a QR code and join URL. Players scan the code, enter their name, and join the lobby. The game starts automatically based on timer rules — no facilitator needed.&lt;/p&gt;
&lt;p&gt;After a game finishes, results are shown for a period, then a new lobby auto-creates.&lt;/p&gt;
&lt;h2&gt;State Machine (Updated)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;AUTO_LOBBY → COUNTDOWN → PLAYING → REVIEWING → RESULTS → COOLDOWN → AUTO_LOBBY
                 ↑                      │
                 └── (next question) ───┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;CREATED and LOBBY are merged into AUTO_LOBBY&lt;/strong&gt; — the system creates and opens the lobby in one step.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FINISHED is replaced by COOLDOWN&lt;/strong&gt; — results display for a set period, then the cycle restarts.&lt;/p&gt;
&lt;h2&gt;Auto-Lobby Creation&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Trigger:&lt;/strong&gt; GET &lt;code&gt;/&lt;/code&gt; (root URL)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;System behaviour:&lt;/strong&gt;&lt;br /&gt;
1. Select a random question set from the available pool&lt;br /&gt;
2. Create a new game with default config (10 questions, 60s per question)&lt;br /&gt;
3. Generate human-readable game code (e.g., &amp;quot;Blue-Dancing-Horse&amp;quot;)&lt;br /&gt;
4. Display common screen with:&lt;br /&gt;
- QR code encoding the player join URL&lt;br /&gt;
- Human-readable game code&lt;br /&gt;
- Join URL as text&lt;br /&gt;
- Lobby countdown timer (starts at 5:00 when first player joins)&lt;br /&gt;
- Player list (updates as players join via SSE)&lt;/p&gt;
&lt;h2&gt;Lobby Timer Rules&lt;/h2&gt;
&lt;p&gt;The lobby timer governs when the game auto-starts. Three conditions, &lt;strong&gt;whichever comes first&lt;/strong&gt;:&lt;/p&gt;
&lt;h3&gt;Condition 1: Timeout with no new players&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Timer starts at &lt;strong&gt;5:00&lt;/strong&gt; (5 minutes) when the &lt;strong&gt;first player joins&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Timer counts down visibly on the common screen&lt;/li&gt;
&lt;li&gt;If timer reaches 0:00 and at least 1 player has joined → &lt;strong&gt;game starts&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Condition 2: Rolling extension for new players&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Each new player joining &lt;strong&gt;resets the timer to 0:30&lt;/strong&gt; if less than 30 seconds remain&lt;/li&gt;
&lt;li&gt;This allows latecomers to extend the lobby window&lt;/li&gt;
&lt;li&gt;The extension only applies when the timer is below 30 seconds&lt;/li&gt;
&lt;li&gt;Example sequence:&lt;/li&gt;
&lt;li&gt;Timer at 0:22 → new player joins → timer resets to 0:30&lt;/li&gt;
&lt;li&gt;Timer at 0:15 → new player joins → timer resets to 0:30&lt;/li&gt;
&lt;li&gt;Timer at 1:45 → new player joins → timer stays at 1:45 (above 30s, no extension)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Condition 3: Max players reached&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;When &lt;strong&gt;40 players&lt;/strong&gt; have joined → &lt;strong&gt;game starts immediately&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No waiting for the timer&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Minimum players&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1 player&lt;/strong&gt; minimum required to start&lt;/li&gt;
&lt;li&gt;If timer expires with 0 players, timer resets and waits for the first player&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Player Join Flow (Unchanged core, simplified entry)&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Player scans QR code on common screen&lt;/li&gt;
&lt;li&gt;Lands on &lt;code&gt;/play/{code}&lt;/code&gt; — join page&lt;/li&gt;
&lt;li&gt;Enters player name (email remains optional)&lt;/li&gt;
&lt;li&gt;POST &lt;code&gt;/play/{code}/join&lt;/code&gt; registers player&lt;/li&gt;
&lt;li&gt;Player sees &amp;quot;Get Ready&amp;quot; screen with lobby countdown&lt;/li&gt;
&lt;li&gt;SSE keeps player updated on lobby state and game start&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Auto-Progression Through Questions&lt;/h2&gt;
&lt;p&gt;With no facilitator, the game must auto-advance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;COUNTDOWN&lt;/strong&gt; (5 seconds) → &lt;strong&gt;PLAYING&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PLAYING&lt;/strong&gt; → question timer runs (e.g., 60 seconds)&lt;/li&gt;
&lt;li&gt;When timer expires OR all players have answered → &lt;strong&gt;REVIEWING&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;REVIEWING&lt;/strong&gt; lasts a fixed duration (e.g., 10 seconds) → auto-advance&lt;/li&gt;
&lt;li&gt;If more questions remain → back to &lt;strong&gt;COUNTDOWN&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;If no more questions → &lt;strong&gt;RESULTS&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Results and Restart Cycle&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;RESULTS&lt;/strong&gt; displayed for a fixed period (e.g., 60 seconds)&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;Rankings, winner highlight, stats&lt;/li&gt;
&lt;li&gt;Rolling credits animation&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;COOLDOWN&lt;/strong&gt; → brief pause (e.g., 10 seconds)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AUTO_LOBBY&lt;/strong&gt; → new game session auto-created with a new random question set&lt;/li&gt;
&lt;li&gt;Cycle repeats indefinitely&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The common screen never goes blank or requires manual intervention.&lt;/p&gt;
&lt;h2&gt;API Changes Required&lt;/h2&gt;
&lt;h3&gt;Removed endpoints (facilitator)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;~~POST /api/games~~ — replaced by auto-creation on root URL visit&lt;/li&gt;
&lt;li&gt;~~PUT /api/games/{code}/config~~ — defaults are used&lt;/li&gt;
&lt;li&gt;~~POST /api/games/{code}/start~~ — auto-start replaces manual start&lt;/li&gt;
&lt;li&gt;~~POST /api/games/{code}/next~~ — auto-advance replaces manual next&lt;/li&gt;
&lt;li&gt;~~POST /api/games/{code}/halt~~ — no emergency stop needed without facilitator&lt;/li&gt;
&lt;li&gt;~~POST /api/games/{code}/end~~ — auto-transition to cooldown&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;New/modified endpoints&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GET /&lt;/strong&gt; — creates auto-lobby, returns common screen&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GET /api/games/{code}/lobby-timer&lt;/strong&gt; — returns remaining lobby time and player count&lt;/li&gt;
&lt;li&gt;SSE events should include &lt;code&gt;lobby-timer&lt;/code&gt; updates when players join (timer extension)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Kept endpoints (player + common screen)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;POST /api/games/{code}/players — join game (only during AUTO_LOBBY)&lt;/li&gt;
&lt;li&gt;GET /api/games/{code}/questions/current — get current question&lt;/li&gt;
&lt;li&gt;POST /api/games/{code}/questions/{index}/answers — submit answer&lt;/li&gt;
&lt;li&gt;GET /api/games/{code}/results — get final results&lt;/li&gt;
&lt;li&gt;GET /api/games/{code}/state — common screen state&lt;/li&gt;
&lt;li&gt;All SSE endpoints&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Web Routes (Updated)&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Route &lt;/th&gt;&lt;th&gt; Purpose &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; GET &lt;code&gt;/&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Auto-create lobby, render common screen &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; GET &lt;code&gt;/screen/{code}&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Common screen for a specific game &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; SSE &lt;code&gt;/screen/{code}/events&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Real-time common screen updates &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; GET &lt;code&gt;/play/{code}&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Player join page &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; POST &lt;code&gt;/play/{code}/join&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Register player &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; POST &lt;code&gt;/play/{code}/answer&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Submit answer &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; SSE &lt;code&gt;/play/{code}/events&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Real-time player updates &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;/facilitator&lt;/code&gt; routes are removed.&lt;/p&gt;
&lt;h2&gt;Common Screen Display States&lt;/h2&gt;
&lt;h3&gt;AUTO_LOBBY (waiting for players)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Large QR code (center)&lt;/li&gt;
&lt;li&gt;Join URL and game code (below QR)&lt;/li&gt;
&lt;li&gt;Lobby countdown timer (prominent, top)&lt;/li&gt;
&lt;li&gt;Player list (scrolling, updates live via SSE)&lt;/li&gt;
&lt;li&gt;&amp;quot;Scan to join!&amp;quot; call to action&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;COUNTDOWN (between questions)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Question number indicator (&amp;quot;Question 3 of 10&amp;quot;)&lt;/li&gt;
&lt;li&gt;Countdown animation (5, 4, 3, 2, 1)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;PLAYING (question active)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Question text at top (readable from distance)&lt;/li&gt;
&lt;li&gt;Question illustration mid-right&lt;/li&gt;
&lt;li&gt;Timer countdown mid-left&lt;/li&gt;
&lt;li&gt;Players joined / answers received counter&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;REVIEWING (after question)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Correct answer revealed&lt;/li&gt;
&lt;li&gt;Stats: first to answer, best delta, median delta&lt;/li&gt;
&lt;li&gt;Auto-advances after fixed duration&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;RESULTS (game over)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Ranked leaderboard&lt;/li&gt;
&lt;li&gt;Winner highlight&lt;/li&gt;
&lt;li&gt;Response time stats&lt;/li&gt;
&lt;li&gt;&amp;quot;Next game starting in...&amp;quot; countdown&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configuration Defaults&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Parameter &lt;/th&gt;&lt;th&gt; Default &lt;/th&gt;&lt;th&gt; Notes &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; Lobby timeout &lt;/td&gt;&lt;td&gt; 300s (5 min) &lt;/td&gt;&lt;td&gt; From first player join &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Lobby extension &lt;/td&gt;&lt;td&gt; 30s &lt;/td&gt;&lt;td&gt; Added when player joins with &amp;lt;30s remaining &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Max players &lt;/td&gt;&lt;td&gt; 40 &lt;/td&gt;&lt;td&gt; Immediate start when reached &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Min players &lt;/td&gt;&lt;td&gt; 1 &lt;/td&gt;&lt;td&gt; Must have at least 1 to start &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Questions per game &lt;/td&gt;&lt;td&gt; 10 &lt;/td&gt;&lt;td&gt; From question set &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Question time limit &lt;/td&gt;&lt;td&gt; 60s &lt;/td&gt;&lt;td&gt; Per question &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Countdown between questions &lt;/td&gt;&lt;td&gt; 5s &lt;/td&gt;&lt;td&gt; Before each question &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Review duration &lt;/td&gt;&lt;td&gt; 10s &lt;/td&gt;&lt;td&gt; Auto-advance after review &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Results display &lt;/td&gt;&lt;td&gt; 60s &lt;/td&gt;&lt;td&gt; Before auto-restart &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Cooldown &lt;/td&gt;&lt;td&gt; 10s &lt;/td&gt;&lt;td&gt; Between results and new lobby &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Question set selection &lt;/td&gt;&lt;td&gt; Random &lt;/td&gt;&lt;td&gt; Rotate through available sets &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

      </description>
    </item>
    <item>
      <title>The Reviewing Phase: Aggregate Feedback Without Attribution</title>
      <link>https://www.inio.no/blog/2026-03-11-reviewing-phase-specification.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-reviewing-phase-specification.html</guid>
      <description>
	&lt;h1&gt;REVIEWING Phase — Functional Specification&lt;/h1&gt;
&lt;h2&gt;1. Purpose of REVIEWING&lt;/h2&gt;
&lt;p&gt;The REVIEWING phase exists to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Replay each question&lt;/li&gt;
&lt;li&gt;Show objective outcomes (correct answer, distributions)&lt;/li&gt;
&lt;li&gt;Provide non-personal statistical insight&lt;/li&gt;
&lt;li&gt;Progress collectively, not individually&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is read-only, time-independent, and player-synchronized.&lt;/p&gt;
&lt;h2&gt;2. Scope &amp;amp; Non-Goals&lt;/h2&gt;
&lt;h3&gt;In scope&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Cycling through questions&lt;/li&gt;
&lt;li&gt;Displaying:&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Original question&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Correct answer&lt;/li&gt;
&lt;li&gt;Aggregated statistics&lt;/li&gt;
&lt;li&gt;Derived “interesting facts”&lt;/li&gt;
&lt;li&gt;Coordinated “Next” progression&lt;/li&gt;
&lt;li&gt;Persisting player scores&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Explicitly out of scope&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Player identity disclosure&lt;/li&gt;
&lt;li&gt;Individual answer attribution&lt;/li&gt;
&lt;li&gt;Real-time answering&lt;/li&gt;
&lt;li&gt;Score changes&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. Core Concepts (Mental Model)&lt;/h2&gt;
&lt;h3&gt;Question Review Unit (QRU)&lt;/h3&gt;
&lt;p&gt;Each question produces one immutable review unit containing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Question text&lt;/li&gt;
&lt;li&gt;Correct answer&lt;/li&gt;
&lt;li&gt;Aggregated answers&lt;/li&gt;
&lt;li&gt;Derived statistics&lt;/li&gt;
&lt;li&gt;Derived facts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;QRUs are processed sequentially.&lt;/p&gt;
&lt;h2&gt;4. Data Responsibilities&lt;/h2&gt;
&lt;h3&gt;4.1 Player Scores (Persistent)&lt;/h3&gt;
&lt;p&gt;Must be stored before REVIEWING starts.&lt;/p&gt;
&lt;p&gt;Each player has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Final score&lt;/li&gt;
&lt;li&gt;Optional accuracy metrics (e.g. % correct)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Scores are immutable during REVIEWING&lt;/li&gt;
&lt;li&gt;Scores may be displayed globally or privately&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4.2 Question Result Dataset (Per Question)&lt;/h3&gt;
&lt;p&gt;For each question:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Question text&lt;/li&gt;
&lt;li&gt;Correct answer&lt;/li&gt;
&lt;li&gt;All submitted answers (anonymous)&lt;/li&gt;
&lt;li&gt;Count of players who answered&lt;/li&gt;
&lt;li&gt;Distribution data (bins / groups)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This dataset is input to statistics &amp;amp; facts engines.&lt;/p&gt;
&lt;h2&gt;5. Statistics Engine (Deterministic)&lt;/h2&gt;
&lt;h3&gt;Purpose&lt;/h3&gt;
&lt;p&gt;Transform raw answer data into neutral, aggregated metrics.&lt;/p&gt;
&lt;h3&gt;Required Outputs (minimum)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;% correct&lt;/li&gt;
&lt;li&gt;Answer distribution (e.g. histogram)&lt;/li&gt;
&lt;li&gt;Mean / median / spread (where applicable)&lt;/li&gt;
&lt;li&gt;Outlier detection (statistical, not personal)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No player identifiers&lt;/li&gt;
&lt;li&gt;Same input → same output&lt;/li&gt;
&lt;li&gt;Must complete before facts generation&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6. Random Fact Engine (Controlled Randomness)&lt;/h2&gt;
&lt;h3&gt;Purpose&lt;/h3&gt;
&lt;p&gt;Generate 1–X interesting, non-personal facts per question.&lt;/p&gt;
&lt;h3&gt;Inputs&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Question Result Dataset&lt;/li&gt;
&lt;li&gt;Statistics Engine output&lt;/li&gt;
&lt;li&gt;Random seed (session-scoped)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Output Characteristics&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;True&lt;/li&gt;
&lt;li&gt;Non-identifying&lt;/li&gt;
&lt;li&gt;Derived from group behavior&lt;/li&gt;
&lt;li&gt;Explainable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example Fact Types:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“Most players underestimated the answer”&lt;/li&gt;
&lt;li&gt;“This question had the widest spread of answers”&lt;/li&gt;
&lt;li&gt;“Accuracy improved compared to the previous question”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Facts are derived, never raw&lt;/li&gt;
&lt;li&gt;No mention of individuals&lt;/li&gt;
&lt;li&gt;Upper bound X is fixed per session&lt;/li&gt;
&lt;li&gt;Facts are generated once, then frozen&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;7. REVIEWING Flow Control&lt;/h2&gt;
&lt;h3&gt;State Variables&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;currentQuestionIndex&lt;/li&gt;
&lt;li&gt;playersReadyCount&lt;/li&gt;
&lt;li&gt;totalPlayers&lt;/li&gt;
&lt;li&gt;advanceThreshold (e.g. 60%)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Next Event Aggregation&lt;/h3&gt;
&lt;p&gt;Player Action:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Player presses Next&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;System Behavior:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Record readiness (idempotent)&lt;/li&gt;
&lt;li&gt;Aggregate count&lt;/li&gt;
&lt;li&gt;Compute readiness ratio&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Transition Rule:&lt;br /&gt;
When&lt;br /&gt;
playersReadyCount / totalPlayers ≥ advanceThreshold&lt;/p&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Advance to next question&lt;/li&gt;
&lt;li&gt;Reset readiness&lt;/li&gt;
&lt;li&gt;Broadcast transition&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Safeguards:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A player pressing Next multiple times counts once&lt;/li&gt;
&lt;li&gt;Late joiners do not affect threshold&lt;/li&gt;
&lt;li&gt;Disconnects freeze their last state&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;8. State Transitions&lt;/h2&gt;
&lt;h3&gt;REVIEWING → REVIEWING&lt;/h3&gt;
&lt;p&gt;Occurs when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Threshold reached&lt;/li&gt;
&lt;li&gt;More questions remain&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;REVIEWING → FINISHED&lt;/h3&gt;
&lt;p&gt;Occurs when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Last question review completes&lt;/li&gt;
&lt;li&gt;Threshold reached&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;9. Invariants (Must Always Hold)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No score changes during REVIEWING&lt;/li&gt;
&lt;li&gt;No personal data exposed&lt;/li&gt;
&lt;li&gt;All players see the same question at the same time&lt;/li&gt;
&lt;li&gt;Facts do not change once generated&lt;/li&gt;
&lt;li&gt;Advancement is collective, not individual&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;10. Test Strategy&lt;/h2&gt;
&lt;h3&gt;10.1 Static Validation Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Every question has:&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Correct answer&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Answer dataset&lt;/li&gt;
&lt;li&gt;Statistics engine produces output for all question types&lt;/li&gt;
&lt;li&gt;Fact engine produces ≤ X facts&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.2 Statistics Engine Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Same dataset → same statistics&lt;/li&gt;
&lt;li&gt;Percentages sum correctly&lt;/li&gt;
&lt;li&gt;No division by zero&lt;/li&gt;
&lt;li&gt;Works with:&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;All correct&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;All incorrect&lt;/li&gt;
&lt;li&gt;Single-player edge case&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.3 Fact Engine Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;No fact references individuals&lt;/li&gt;
&lt;li&gt;No raw answers leaked&lt;/li&gt;
&lt;li&gt;Same seed → same facts&lt;/li&gt;
&lt;li&gt;Different seed → different selection, same truth&lt;/li&gt;
&lt;li&gt;Produces 0 facts if dataset too small&lt;/li&gt;
&lt;li&gt;Never exceeds X&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.4 REVIEWING Flow Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Advance only after threshold&lt;/li&gt;
&lt;li&gt;Advance exactly once per question&lt;/li&gt;
&lt;li&gt;Multiple Next presses don’t skew count&lt;/li&gt;
&lt;li&gt;All players see same index&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.5 Failure &amp;amp; Edge Tests&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Player disconnects mid-review&lt;/li&gt;
&lt;li&gt;Player reconnects&lt;/li&gt;
&lt;li&gt;Threshold exactly met&lt;/li&gt;
&lt;li&gt;Threshold exceeded&lt;/li&gt;
&lt;li&gt;Only one player&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;11. Acceptance Criterion&lt;/h2&gt;
&lt;p&gt;A player can understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;what the correct answer was,&lt;/li&gt;
&lt;li&gt;how people answered in general,&lt;/li&gt;
&lt;li&gt;something interesting about the group,&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;without learning anything personal.&lt;/p&gt;
&lt;p&gt;If yes, REVIEWING works.&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>JSON API Details</title>
      <link>https://www.inio.no/blog/2026-03-11-json-api-payloads.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-json-api-payloads.html</guid>
      <description>
	&lt;h1&gt;Game HTTP API — JSON Payload Details&lt;/h1&gt;
&lt;p&gt;API to start a game and retrieve &lt;code&gt;{key}-json-game-data-file.json&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;QuestionSet Example&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;code&amp;quot;: &amp;quot;Blue-Dancing-Horse&amp;quot;,
  &amp;quot;startTime&amp;quot;: null,
  &amp;quot;questions&amp;quot;: [
    {
      &amp;quot;questionIndex&amp;quot;: 1,
      &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://url/media-hash&amp;quot;,
      &amp;quot;questionText&amp;quot;: &amp;quot;What is the wingspan of a Norwegian Boeing 747 in {$1}&amp;quot;,
      &amp;quot;isoUnit&amp;quot;: &amp;quot;m&amp;quot;,
      &amp;quot;defaultMax&amp;quot;: null,
      &amp;quot;defaultMin&amp;quot;: -100,
      &amp;quot;correctValue&amp;quot;: null,
      &amp;quot;questionTimeLimitSec&amp;quot;: 60
    },
    {
      &amp;quot;questionIndex&amp;quot;: 2,
      &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://url/media-hash&amp;quot;,
      &amp;quot;questionText&amp;quot;: &amp;quot;Number of McDonald&apos;s Double Cheese meals served each day in {$1}&amp;quot;,
      &amp;quot;isoUnit&amp;quot;: &amp;quot;units&amp;quot;,
      &amp;quot;defaultMax&amp;quot;: null,
      &amp;quot;defaultMin&amp;quot;: -100,
      &amp;quot;correctValue&amp;quot;: null,
      &amp;quot;questionTimeLimitSec&amp;quot;: 60
    },
    {
      &amp;quot;questionIndex&amp;quot;: 3,
      &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://url/media-hash&amp;quot;,
      &amp;quot;questionText&amp;quot;: &amp;quot;Height of Seip Mountain in {$1}&amp;quot;,
      &amp;quot;isoUnit&amp;quot;: &amp;quot;m&amp;quot;,
      &amp;quot;defaultMax&amp;quot;: null,
      &amp;quot;defaultMin&amp;quot;: -100,
      &amp;quot;correctValue&amp;quot;: null,
      &amp;quot;questionTimeLimitSec&amp;quot;: 60
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Answer Example&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;questionId&amp;quot;: 1,
  &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;,
  &amp;quot;userMin&amp;quot;: 55,
  &amp;quot;userMax&amp;quot;: 72,
  &amp;quot;submittedAt&amp;quot;: 1700000042000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;ScoreResult Example&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;rankings&amp;quot;: [
    { &amp;quot;rank&amp;quot;: 1, &amp;quot;playerId&amp;quot;: &amp;quot;p-3&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Carol&amp;quot; },
    { &amp;quot;rank&amp;quot;: 2, &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot; }
  ],
  &amp;quot;fastestAvgResponseMs&amp;quot;: { &amp;quot;playerId&amp;quot;: &amp;quot;p-3&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Carol&amp;quot;, &amp;quot;avgMs&amp;quot;: 8200 },
  &amp;quot;slowestAvgResponseMs&amp;quot;: { &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot;, &amp;quot;avgMs&amp;quot;: 14500 }
}
&lt;/code&gt;&lt;/pre&gt;

      </description>
    </item>
    <item>
      <title>Estimation Feedback Loop</title>
      <link>https://www.inio.no/blog/2026-03-11-estimation-feedback-loop.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-estimation-feedback-loop.html</guid>
      <description>
	&lt;h1&gt;Project Planning in the Age of AI: What Can History Teach Us?&lt;/h1&gt;
&lt;p&gt;&lt;em&gt;Working notes toward a longer discussion. This is a living document.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;The Estimation Problem&lt;/h2&gt;
&lt;p&gt;Every project begins with a guess. We call it an &amp;quot;estimate&amp;quot; to give it dignity, but Daniel Kahneman would call it what it is: a prediction made under uncertainty, distorted by cognitive biases we cannot feel operating.&lt;/p&gt;
&lt;p&gt;In &lt;em&gt;Thinking, Fast and Slow&lt;/em&gt;, Kahneman describes the &lt;strong&gt;planning fallacy&lt;/strong&gt; — our systematic tendency to underestimate the time, cost, and risk of future actions while overestimating their benefits. This isn&apos;t a character flaw. It&apos;s how human cognition works. System 1 (fast, intuitive) generates an optimistic anchor. System 2 (slow, analytical) is too lazy to adjust it far enough.&lt;/p&gt;
&lt;p&gt;The result is predictable: projects run late. Not randomly late — &lt;em&gt;systematically&lt;/em&gt; late, in the same direction, by roughly the same factor.&lt;/p&gt;
&lt;p&gt;The question is: can we do better? And specifically — can AI agents, with access to structured memory and historical data, break the cycle?&lt;/p&gt;
&lt;h2&gt;What Precision Demands&lt;/h2&gt;
&lt;p&gt;Simon Winchester&apos;s &lt;em&gt;The Perfectionists&lt;/em&gt; traces the history of precision engineering — from John Wilkinson&apos;s cannon borer to the nanometer tolerances of modern semiconductors. A recurring theme: &lt;strong&gt;precision improves only when you measure the gap between intention and outcome, then feed that measurement back into the process.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Wilkinson didn&apos;t bore better cylinders by trying harder. He bore better cylinders by measuring the deviation of each one and adjusting the machine. The feedback loop &lt;em&gt;is&lt;/em&gt; the precision.&lt;/p&gt;
&lt;p&gt;Software estimation has no such loop. We estimate, we execute, we ship (or slip), and then we estimate the next thing as if the last one never happened. The data exists — in our git logs, our issue trackers, our CI pipelines — but we never close the circle.&lt;/p&gt;
&lt;h2&gt;The KT Framework Applied to Estimation&lt;/h2&gt;
&lt;p&gt;Kepner and Tregoe gave us a framework for structured thinking: &lt;strong&gt;Situation Appraisal&lt;/strong&gt;, &lt;strong&gt;Problem Analysis&lt;/strong&gt;, &lt;strong&gt;Decision Analysis&lt;/strong&gt;, and &lt;strong&gt;Potential Problem Analysis&lt;/strong&gt;. Their insight was that most failures come not from lack of intelligence but from lack of structured process — people jump to solutions before understanding the situation.&lt;/p&gt;
&lt;p&gt;Applied to estimation, the KT framework asks:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Situation Appraisal&lt;/strong&gt;: What do we actually know? What data do we have from previous similar tasks? What is the scope of our uncertainty?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem Analysis&lt;/strong&gt;: When our estimates were wrong, &lt;em&gt;why&lt;/em&gt; were they wrong? Was the task more complex than anticipated? Did we underestimate integration effort? Did rework from failures consume the buffer?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decision Analysis&lt;/strong&gt;: Given what we know about our historical accuracy, which estimation method gives us the best prediction? Raw intuition? Three-point estimates? Reference class forecasting? Monte Carlo simulation?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Potential Problem Analysis&lt;/strong&gt;: What could make this estimate wrong? What would we do if it is? Where are the risks concentrated?&lt;/p&gt;
&lt;p&gt;Most teams skip straight to &amp;quot;how long will this take?&amp;quot; and answer from intuition. KT says: slow down. Examine the situation. Look at the data.&lt;/p&gt;
&lt;h2&gt;The Data We Already Have&lt;/h2&gt;
&lt;p&gt;Consider what a modern development environment produces automatically:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Source &lt;/th&gt;&lt;th&gt; Signal &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; Git commit history &lt;/td&gt;&lt;td&gt; When work actually started and ended &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Issue tracker (open → close) &lt;/td&gt;&lt;td&gt; Elapsed calendar time per task &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; CI pipeline history &lt;/td&gt;&lt;td&gt; Build time, failure rate, rework cycles &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; DORA metrics &lt;/td&gt;&lt;td&gt; Deployment frequency, lead time, change failure rate, recovery time &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; Agent memory (Neo4j) &lt;/td&gt;&lt;td&gt; What was known, what was decided, what was learned &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;We measured our own DORA metrics today. Deployment frequency: elite. Lead time: elite. MTTR: elite. Change failure rate: &lt;strong&gt;68%&lt;/strong&gt; — low performer.&lt;/p&gt;
&lt;p&gt;That 68% is not just a quality metric. It&apos;s an &lt;strong&gt;estimation input&lt;/strong&gt;. If two-thirds of deployments fail and require a fix-forward cycle, then every task estimate needs a rework multiplier. Ignoring this is like Wilkinson ignoring that his bore drifts left — the error is systematic and correctable, if you measure it.&lt;/p&gt;
&lt;h2&gt;Reference Class Forecasting&lt;/h2&gt;
&lt;p&gt;Kahneman and Tversky proposed &lt;strong&gt;reference class forecasting&lt;/strong&gt; as the antidote to the planning fallacy. Instead of asking &amp;quot;how long will &lt;em&gt;this&lt;/em&gt; task take?&amp;quot;, ask: &amp;quot;how long did &lt;em&gt;similar&lt;/em&gt; tasks take?&amp;quot;&lt;/p&gt;
&lt;p&gt;The method:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Identify a reference class of comparable past tasks&lt;/li&gt;
&lt;li&gt;Obtain the statistical distribution of outcomes in that class&lt;/li&gt;
&lt;li&gt;Use that distribution as the baseline prediction&lt;/li&gt;
&lt;li&gt;Adjust only for specific, articulable differences&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is powerful because it bypasses the inside view (our optimistic story about &lt;em&gt;this particular task&lt;/em&gt;) and forces us to confront the outside view (what actually happened last time, and the time before that).&lt;/p&gt;
&lt;p&gt;For software teams, the reference class is rich:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;quot;Refactoring&amp;quot; tasks: how long did previous refactors actually take vs. estimate?&lt;/li&gt;
&lt;li&gt;&amp;quot;Bug fixes&amp;quot; after failed deployments: what&apos;s the average rework time?&lt;/li&gt;
&lt;li&gt;Tasks assigned to a specific agent: what&apos;s their personal estimation bias?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Three-Point Estimates and Monte Carlo — Necessary but Not Sufficient&lt;/h2&gt;
&lt;p&gt;Our scheduling tool, &lt;code&gt;tj&lt;/code&gt;, already supports three-point estimates (optimistic, most likely, pessimistic) and runs Monte Carlo simulations using PERT-Beta distributions. This gives us P50, P80, and P95 confidence intervals.&lt;/p&gt;
&lt;p&gt;But three-point estimates have a hidden assumption: &lt;strong&gt;that the estimator is well-calibrated&lt;/strong&gt;. If your &amp;quot;pessimistic&amp;quot; estimate is actually your &amp;quot;most likely&amp;quot; outcome (a common pattern), then even 10,000 Monte Carlo simulations will produce confidently wrong predictions.&lt;/p&gt;
&lt;p&gt;The simulation is only as good as the distribution it samples from.&lt;/p&gt;
&lt;h2&gt;Closing the Loop: Calibrated Estimation&lt;/h2&gt;
&lt;p&gt;What we need is a feedback mechanism with three components:&lt;/p&gt;
&lt;h3&gt;A. Retrospective Comparison&lt;/h3&gt;
&lt;p&gt;After a task completes, compare estimate to actual:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;tasks:
  - id: implement-sse
    effort: { optimistic: 2d, likely: 4d, pessimistic: 8d }
    actual: 6d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From many such comparisons, compute:&lt;br /&gt;
- &lt;strong&gt;Bias factor&lt;/strong&gt; per resource: &lt;code&gt;mean(actual / likely)&lt;/code&gt; — are they consistently 1.3x or 0.8x?&lt;br /&gt;
- &lt;strong&gt;Spread accuracy&lt;/strong&gt;: do 80% of actuals fall within the estimated P20–P80 range?&lt;br /&gt;
- &lt;strong&gt;Task-type patterns&lt;/strong&gt;: refactors take 2x, bugfixes take 0.5x of estimated effort&lt;/p&gt;
&lt;h3&gt;B. Calibrated Distributions&lt;/h3&gt;
&lt;p&gt;Feed the bias factors back into future estimates. If an agent historically underestimates by 1.4x, shift their PERT distribution rightward before running Monte Carlo. The math is straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;calibrated_likely = raw_likely * bias_factor
calibrated_pessimistic = raw_pessimistic * spread_factor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result: Monte Carlo predictions that reflect &lt;em&gt;demonstrated&lt;/em&gt; accuracy, not assumed accuracy.&lt;/p&gt;
&lt;h3&gt;C. Automatic Data Collection&lt;/h3&gt;
&lt;p&gt;The richest approach: don&apos;t ask humans (or agents) to self-report actuals. &lt;strong&gt;Import them from the system of record.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Git: first commit on branch → merge to main = working time&lt;/li&gt;
&lt;li&gt;Gitea: issue opened → issue closed = calendar time&lt;/li&gt;
&lt;li&gt;Woodpecker: pipeline count per issue = rework cycles&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This removes the burden of manual retrospectives and ensures the data is honest.&lt;/p&gt;
&lt;h2&gt;The Agent Memory Connection&lt;/h2&gt;
&lt;p&gt;Here is where AI agents change the game. A human developer forgets their estimation patterns. They don&apos;t track that they underestimate &amp;quot;refactoring&amp;quot; tasks by 40% and overestimate &amp;quot;adding tests&amp;quot; by 20%. The bias operates invisibly.&lt;/p&gt;
&lt;p&gt;An AI agent with structured memory can:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Remember&lt;/strong&gt; the estimate it gave for each task&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Record&lt;/strong&gt; the actual outcome when the task completes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compute&lt;/strong&gt; its own calibration factors — per task type, per project, per complexity level&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Apply&lt;/strong&gt; those factors automatically to future estimates&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The agent doesn&apos;t need to be told it has a bias. It can discover the bias from data, and correct for it. This is reference class forecasting, automated.&lt;/p&gt;
&lt;p&gt;Our shared memory graph (Neo4j) already stores project context, decisions, and agent knowledge. Extending it to store estimation-vs-actual pairs is a natural step. The calibration factors become part of the agent&apos;s evolving knowledge — improving with every completed task.&lt;/p&gt;
&lt;h2&gt;The Vision: Task, History, Knowledge&lt;/h2&gt;
&lt;p&gt;The connection we&apos;re building toward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New Task → &amp;quot;Have we done this before?&amp;quot;
              ↓
        Reference class lookup in memory
              ↓
        Historical bias factors applied
              ↓
   Agent&apos;s calibrated three-point estimate
              ↓
        Monte Carlo simulation (tj)
              ↓
      P50 / P80 / P95 with earned confidence
              ↓
            Execute
              ↓
        Record actual outcome
              ↓
      Update memory → improve next estimate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each task feeds forward into better predictions for the next one. The agent&apos;s knowledge compounds. The estimates get more honest — not because the agent tries harder, but because the feedback loop enforces it mechanically.&lt;/p&gt;
&lt;p&gt;This is Winchester&apos;s lesson from precision engineering: you don&apos;t improve by wanting to be more accurate. You improve by measuring the error and correcting for it, systematically, every time.&lt;/p&gt;
&lt;h2&gt;What This Means for Project Planning&lt;/h2&gt;
&lt;p&gt;Traditional project planning tools (MS Project, Jira, etc.) treat estimates as inputs and schedules as outputs. They never question whether the inputs are any good.&lt;/p&gt;
&lt;p&gt;A calibration-aware planning tool inverts this: &lt;strong&gt;past outcomes are inputs&lt;/strong&gt;, and the schedule reflects demonstrated capability rather than hopeful prediction.&lt;/p&gt;
&lt;p&gt;For a small team with AI agents, this means:&lt;br /&gt;
- The planning tool gets smarter with every sprint&lt;br /&gt;
- Estimates are grounded in evidence, not anchoring bias&lt;br /&gt;
- Confidence intervals actually mean what they claim (P80 = 80%)&lt;br /&gt;
- The team can trust its own predictions — and stakeholders can too&lt;/p&gt;
&lt;h2&gt;Open Questions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;How many data points are needed before calibration factors are reliable? (Likely 10-15 per reference class, per standard statistical guidance.)&lt;/li&gt;
&lt;li&gt;Should calibration be per-agent, per-task-type, or both? (Both — with fallback to global when data is sparse.)&lt;/li&gt;
&lt;li&gt;How do we handle tasks that have no historical reference class? (Fall back to uncalibrated estimates with wider uncertainty bands, and flag them explicitly.)&lt;/li&gt;
&lt;li&gt;Can we detect when a team&apos;s capability has changed (new tools, new architecture) and reset calibration? (Track calibration drift over time — a sudden improvement in bias factor signals a capability shift.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Kahneman, D.&lt;/strong&gt; (2011). &lt;em&gt;Thinking, Fast and Slow&lt;/em&gt;. Farrar, Straus and Giroux. — Planning fallacy, reference class forecasting, System 1/System 2, anchoring bias.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kahneman, D. &amp;amp; Tversky, A.&lt;/strong&gt; (1979). &amp;quot;Intuitive Prediction: Biases and Corrective Procedures.&amp;quot; &lt;em&gt;TIMS Studies in Management Science&lt;/em&gt;, 12. — The original reference class forecasting paper.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Winchester, S.&lt;/strong&gt; (2018). &lt;em&gt;The Perfectionists: How Precision Engineers Created the Modern World&lt;/em&gt;. HarperCollins. — Feedback loops as the mechanism of precision improvement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kepner, C.H. &amp;amp; Tregoe, B.B.&lt;/strong&gt; (1981). &lt;em&gt;The New Rational Manager&lt;/em&gt;. Princeton Research Press. — Structured analysis: situation appraisal, problem analysis, decision analysis, potential problem analysis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forsgren, N., Humble, J. &amp;amp; Kim, G.&lt;/strong&gt; (2018). &lt;em&gt;Accelerate: The Science of Lean Software and DevOps&lt;/em&gt;. IT Revolution Press. — DORA metrics, capabilities of high-performing organizations, data-driven improvement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;McConnell, S.&lt;/strong&gt; (2006). &lt;em&gt;Software Estimation: Demystifying the Black Art&lt;/em&gt;. Microsoft Press. — Cone of uncertainty, estimation techniques, calibration through historical data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flyvbjerg, B.&lt;/strong&gt; (2006). &amp;quot;From Nobel Prize to Project Management: Getting Risks Right.&amp;quot; &lt;em&gt;Project Management Journal&lt;/em&gt;, 37(3). — Applying reference class forecasting to large-scale projects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DORA Team / Google Cloud.&lt;/strong&gt; (2024). &lt;em&gt;Accelerate State of DevOps Report 2024&lt;/em&gt;. — Current benchmarks for elite/high/medium/low software delivery performance.&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>REST API Specification</title>
      <link>https://www.inio.no/blog/2026-03-11-rest-api-specification.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-rest-api-specification.html</guid>
      <description>
	&lt;h1&gt;REST API Specification&lt;/h1&gt;
&lt;p&gt;Base path: &lt;code&gt;/api&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;All request and response bodies are &lt;code&gt;application/json&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Error Format&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;error&amp;quot;: &amp;quot;GAME_NOT_FOUND&amp;quot;,
  &amp;quot;message&amp;quot;: &amp;quot;No game with code &apos;Blue-Dancing-Horse&apos; exists.&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Code &lt;/th&gt;&lt;th&gt; HTTP &lt;/th&gt;&lt;th&gt; Meaning &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; GAME_NOT_FOUND &lt;/td&gt;&lt;td&gt; 404 &lt;/td&gt;&lt;td&gt; Game code does not exist &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; GAME_FULL &lt;/td&gt;&lt;td&gt; 409 &lt;/td&gt;&lt;td&gt; Max players (40) reached &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; PLAYER_NOT_FOUND &lt;/td&gt;&lt;td&gt; 404 &lt;/td&gt;&lt;td&gt; Player not in this game &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; QUESTION_TIMEOUT &lt;/td&gt;&lt;td&gt; 410 &lt;/td&gt;&lt;td&gt; Answer after time limit &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; VALIDATION_ERROR &lt;/td&gt;&lt;td&gt; 400 &lt;/td&gt;&lt;td&gt; Missing or invalid fields &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Player Input&lt;/h2&gt;
&lt;h3&gt;POST /api/games/{code}/players — join&lt;/h3&gt;
&lt;p&gt;Register a player. Allowed in AUTO_LOBBY, COUNTDOWN, PLAYING, REVIEWING.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response &lt;code&gt;201 Created&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot;,
  &amp;quot;gameCode&amp;quot;: &amp;quot;Blue-Dancing-Horse&amp;quot;,
  &amp;quot;state&amp;quot;: &amp;quot;AUTO_LOBBY&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;POST /api/games/{code}/questions/{index}/answers — answer&lt;/h3&gt;
&lt;p&gt;Submit min/max answer for current question. Only in PLAYING state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;,
  &amp;quot;userMin&amp;quot;: 380,
  &amp;quot;userMax&amp;quot;: 450
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response &lt;code&gt;201 Created&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;questionIndex&amp;quot;: 3,
  &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;,
  &amp;quot;delta&amp;quot;: 70,
  &amp;quot;submittedAt&amp;quot;: 1700000042000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;GET /api/games/{code}/questions/current — getQuestion&lt;/h3&gt;
&lt;p&gt;Get the current question. Only in PLAYING state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Response &lt;code&gt;200 OK&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;questionIndex&amp;quot;: 3,
  &amp;quot;questionText&amp;quot;: &amp;quot;How tall is the Empire State Building?&amp;quot;,
  &amp;quot;isoUnit&amp;quot;: &amp;quot;m&amp;quot;,
  &amp;quot;defaultMin&amp;quot;: 0,
  &amp;quot;defaultMax&amp;quot;: 1000,
  &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://media.example.com/media-hash&amp;quot;,
  &amp;quot;timeRemainingMs&amp;quot;: 42000
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;POST /api/games/{code}/next — signalNext&lt;/h3&gt;
&lt;p&gt;Signal readiness to advance to the next question or phase. Used in REVIEWING state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Request:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Response &lt;code&gt;200 OK&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;status&amp;quot;: &amp;quot;OK&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;GET /api/games/{code}/results — getResults&lt;/h3&gt;
&lt;p&gt;Final results. Available in RESULTS, FINISHED, COOLDOWN.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Response &lt;code&gt;200 OK&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;code&amp;quot;: &amp;quot;Blue-Dancing-Horse&amp;quot;,
  &amp;quot;state&amp;quot;: &amp;quot;RESULTS&amp;quot;,
  &amp;quot;rankings&amp;quot;: [
    { &amp;quot;rank&amp;quot;: 1, &amp;quot;playerId&amp;quot;: &amp;quot;p-3&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Carol&amp;quot; },
    { &amp;quot;rank&amp;quot;: 2, &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot; }
  ],
  &amp;quot;fastestAvgResponseMs&amp;quot;: { &amp;quot;playerId&amp;quot;: &amp;quot;p-3&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Carol&amp;quot;, &amp;quot;avgMs&amp;quot;: 8200 },
  &amp;quot;slowestAvgResponseMs&amp;quot;: { &amp;quot;playerId&amp;quot;: &amp;quot;p-2&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot;, &amp;quot;avgMs&amp;quot;: 14500 },
  &amp;quot;achievements&amp;quot;: [
    { &amp;quot;title&amp;quot;: &amp;quot;The Sniper&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Carol&amp;quot;, &amp;quot;detail&amp;quot;: &amp;quot;Smallest total range for correct answers.&amp;quot; },
    { &amp;quot;title&amp;quot;: &amp;quot;The Rock&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;Bob&amp;quot;, &amp;quot;detail&amp;quot;: &amp;quot;Most consistent estimation ranges.&amp;quot; }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Common Screen — Game State&lt;/h2&gt;
&lt;h3&gt;GET /api/games/{code}/state — getGameState&lt;/h3&gt;
&lt;p&gt;Current game state for the common screen display.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Response &lt;code&gt;200 OK&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;code&amp;quot;: &amp;quot;Blue-Dancing-Horse&amp;quot;,
  &amp;quot;state&amp;quot;: &amp;quot;PLAYING&amp;quot;,
  &amp;quot;currentQuestionIndex&amp;quot;: 3,
  &amp;quot;numberOfQuestions&amp;quot;: 10,
  &amp;quot;playersJoined&amp;quot;: 12,
  &amp;quot;answersReceived&amp;quot;: 8,
  &amp;quot;timeRemainingMs&amp;quot;: 42000,
  &amp;quot;question&amp;quot;: {
    &amp;quot;questionText&amp;quot;: &amp;quot;How tall is the Empire State Building?&amp;quot;,
    &amp;quot;isoUnit&amp;quot;: &amp;quot;m&amp;quot;,
    &amp;quot;illustrationUrl&amp;quot;: &amp;quot;https://media.example.com/media-hash&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During AUTO_LOBBY, includes lobby timer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;state&amp;quot;: &amp;quot;AUTO_LOBBY&amp;quot;,
  &amp;quot;lobbyTimeRemainingMs&amp;quot;: 245000,
  &amp;quot;playersJoined&amp;quot;: 5
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During REVIEWING:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;state&amp;quot;: &amp;quot;REVIEWING&amp;quot;,
  &amp;quot;playersReady&amp;quot;: 7,
  &amp;quot;review&amp;quot;: {
    &amp;quot;correctValue&amp;quot;: 443,
    &amp;quot;stats&amp;quot;: {
      &amp;quot;percentCorrect&amp;quot;: 66.7,
      &amp;quot;mean&amp;quot;: 450.5,
      &amp;quot;median&amp;quot;: 445.0,
      &amp;quot;spread&amp;quot;: 120.0
    },
    &amp;quot;facts&amp;quot;: [
      { &amp;quot;text&amp;quot;: &amp;quot;The group has excellent knowledge about this!&amp;quot; },
      { &amp;quot;text&amp;quot;: &amp;quot;We had some wild outliers this round!&amp;quot; }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During FINISHED:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;state&amp;quot;: &amp;quot;FINISHED&amp;quot;,
  &amp;quot;timeRemainingMs&amp;quot;: 25000,
  &amp;quot;results&amp;quot;: { ... }
}
&lt;/code&gt;&lt;/pre&gt;

      </description>
    </item>
    <item>
      <title>Gui</title>
      <link>https://www.inio.no/blog/2026-03-11-game-gui-overview.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-game-gui-overview.html</guid>
      <description>
	&lt;h1&gt;GUI&lt;/h1&gt;
&lt;p&gt;Create for presentation-mode &amp;quot;Horizontal&amp;quot; layout. Think Apple Keynote, and Norman Nielsen Group on usability and design principles.  Don&apos;t make me think!&lt;/p&gt;
&lt;p&gt;Generic: two screen types are connected by a common backend, that represents one game. The common screen auto-creates a new game when the root URL is visited.&lt;/p&gt;
&lt;h2&gt;Users in general&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The user is a commoner from the street, with no previous experience with this game.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Screens in general&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Horizontal view TV, presentation.&lt;/li&gt;
&lt;li&gt;player-screen is meant to be on a mobile (touch-screen)&lt;/li&gt;
&lt;li&gt;common-screen is meant to be on a TV/Projector/theatre-screen&lt;/li&gt;
&lt;li&gt;The game is a presentation, but interactive.&lt;/li&gt;
&lt;li&gt;Error screen (if something happens, that can get you back to start)&lt;/li&gt;
&lt;li&gt;No facilitator screen — the system is the host.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Player-screens&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Skills:&lt;/strong&gt; Generic person from the street&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Affordance:&lt;/strong&gt; Enable the user to participate in the game.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Screenflow&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Scan QR code on common screen&lt;/li&gt;
&lt;li&gt;Splashscreen - register (enter name)&lt;/li&gt;
&lt;li&gt;Waiting screen - show users that register, lobby countdown timer&lt;/li&gt;
&lt;li&gt;Sample question - screen (how to play)&lt;/li&gt;
&lt;li&gt;The rules (how to score points)&lt;/li&gt;
&lt;li&gt;Countdown to first question&lt;/li&gt;
&lt;li&gt;Repeat questions until end&lt;/li&gt;
&lt;li&gt;Wait for final participant&apos;s answer&lt;/li&gt;
&lt;li&gt;For all questions: display answer on common-screen, show your answers on player-screen.&lt;/li&gt;
&lt;li&gt;Late joiners: can enter during COUNTDOWN, PLAYING, or REVIEWING — cannot score on missed questions.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Enter max min values&lt;/h3&gt;
&lt;p&gt;A key portion of the game is to enter values relatively fast. Number input must be easy and efficient. How do we test that?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Let the player-screen reflect common-screen. If you move your eyes, elements should be at the same location.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Common-screen&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Skills:&lt;/strong&gt; Person in a public house, Conference, Internet&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The common screen is the game host. Visiting the root URL (&lt;code&gt;/&lt;/code&gt;) auto-creates a new game session.&lt;/p&gt;
&lt;h3&gt;Display per state&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; State &lt;/th&gt;&lt;th&gt; Display &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;AUTO_LOBBY&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; QR code, join URL, game code, lobby countdown timer (5 min), player list (live via SSE) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;COUNTDOWN&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; &amp;quot;Question N of M&amp;quot;, countdown animation (5, 4, 3, 2, 1) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;PLAYING&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Question text at top, illustration mid-right, timer + answer count mid-left &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;REVIEWING&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Correct answer, winner delta, median delta, first-to-answer &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;RESULTS&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Ranked leaderboard, winner highlight, response time stats &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;COOLDOWN&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; &amp;quot;Next game starting in...&amp;quot; countdown &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;Question on top - so it can be read over the head of the person in front of you.&lt;/li&gt;
&lt;li&gt;Question-illustration/image at the mid/lower - right&lt;/li&gt;
&lt;li&gt;Game information at the mid/lower left&lt;/li&gt;
&lt;li&gt;Countdown to next question&lt;/li&gt;
&lt;li&gt;Number of players/answers&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Error States&lt;/h2&gt;
&lt;p&gt;All screens should handle the following error conditions gracefully:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Error &lt;/th&gt;&lt;th&gt; Shown on &lt;/th&gt;&lt;th&gt; Behaviour &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Connection lost&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; All screens &lt;/td&gt;&lt;td&gt; Show reconnecting indicator. Retry automatically. After timeout, offer &amp;quot;Return to start&amp;quot;. &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Game not found&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Player-screen, Common-screen &lt;/td&gt;&lt;td&gt; &amp;quot;Game does not exist. Check the code and try again.&amp;quot; with input to re-enter code. &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Game full&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; Player-screen &lt;/td&gt;&lt;td&gt; &amp;quot;This game is full (40 players). Wait for the next game.&amp;quot; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Session expired&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; All screens &lt;/td&gt;&lt;td&gt; &amp;quot;Your session has expired.&amp;quot; with option to rejoin (player). &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;

      </description>
    </item>
    <item>
      <title>Data Catalogue</title>
      <link>https://www.inio.no/blog/2026-03-11-domain-data-catalogue.html</link>
      <pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-03-11-domain-data-catalogue.html</guid>
      <description>
	&lt;h1&gt;Domain Data Catalogue&lt;/h1&gt;
&lt;p&gt;This catalogue documents all domain data types used in the game.&lt;br /&gt;
It serves as the authoritative reference for domain concepts, their constraints,&lt;br /&gt;
and their GDPR classification.&lt;/p&gt;
&lt;p&gt;Governed by the &amp;quot;Use Domain-Specific Data Types&amp;quot; ADR (linked from the project KB).&lt;/p&gt;
&lt;h2&gt;Identity Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; Example &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;GameId&lt;/code&gt; &lt;/td&gt;&lt;td&gt; UUID &lt;/td&gt;&lt;td&gt; Non-null, immutable &lt;/td&gt;&lt;td&gt; &lt;code&gt;6c9f1f0e-...&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;PlayerId&lt;/code&gt; &lt;/td&gt;&lt;td&gt; UUID &lt;/td&gt;&lt;td&gt; Non-null, immutable &lt;/td&gt;&lt;td&gt; &lt;code&gt;a3b2c1d0-...&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;QuestionId&lt;/code&gt; &lt;/td&gt;&lt;td&gt; UUID &lt;/td&gt;&lt;td&gt; Non-null, immutable &lt;/td&gt;&lt;td&gt; &lt;code&gt;7d8e9f0a-...&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;QuestionSetId&lt;/code&gt; &lt;/td&gt;&lt;td&gt; UUID &lt;/td&gt;&lt;td&gt; Non-null, immutable &lt;/td&gt;&lt;td&gt; &lt;code&gt;1a2b3c4d-...&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Game Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; Example &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;GameCode&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Non-blank, format: &lt;code&gt;Word-Word-Word&lt;/code&gt; &lt;/td&gt;&lt;td&gt; &lt;code&gt;Blue-Dancing-Horse&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;GameState&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Enum &lt;/td&gt;&lt;td&gt; One of 7 defined states &lt;/td&gt;&lt;td&gt; &lt;code&gt;PLAYING&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;QuestionIndex&lt;/code&gt; &lt;/td&gt;&lt;td&gt; int &lt;/td&gt;&lt;td&gt; &amp;gt;= 0, &amp;lt; numberOfQuestions &lt;/td&gt;&lt;td&gt; &lt;code&gt;3&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;QuestionCount&lt;/code&gt; &lt;/td&gt;&lt;td&gt; int &lt;/td&gt;&lt;td&gt; &amp;gt; 0 &lt;/td&gt;&lt;td&gt; &lt;code&gt;10&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;TimeLimitSeconds&lt;/code&gt; &lt;/td&gt;&lt;td&gt; int &lt;/td&gt;&lt;td&gt; &amp;gt; 0 &lt;/td&gt;&lt;td&gt; &lt;code&gt;60&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;GameState Values&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; State &lt;/th&gt;&lt;th&gt; Description &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;AUTO_LOBBY&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Waiting for players, lobby timer running &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;COUNTDOWN&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Game starting, countdown active &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;PLAYING&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Active question, accepting answers &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;REVIEWING&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Showing answer review for current question &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;RESULTS&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Showing final rankings &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;COOLDOWN&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Post-results cooldown &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;FINISHED&lt;/code&gt; &lt;/td&gt;&lt;td&gt; Game ended &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Player Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; GDPR &lt;/th&gt;&lt;th&gt; Example &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;PlayerName&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Non-blank, max 40 chars &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Personal&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; &lt;code&gt;Bob&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;PlayerEmail&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Valid email format &lt;/td&gt;&lt;td&gt; &lt;strong&gt;Personal&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; &lt;code&gt;bob@example.com&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;GDPR Notes — Player Data&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PlayerName&lt;/code&gt; and &lt;code&gt;PlayerEmail&lt;/code&gt; are personal data under GDPR&lt;/li&gt;
&lt;li&gt;Must be deletable on request (right to erasure)&lt;/li&gt;
&lt;li&gt;Retention: tied to game lifecycle — delete after game ends or on request&lt;/li&gt;
&lt;li&gt;Purpose: identifying players during gameplay&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Question Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; Example &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;QuestionText&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Non-blank &lt;/td&gt;&lt;td&gt; &lt;code&gt;How tall is the Empire State Building?&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;IsoUnit&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Non-blank, ISO unit code &lt;/td&gt;&lt;td&gt; &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;km&lt;/code&gt;, &lt;code&gt;presidents&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;RangeMin&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; &amp;lt; RangeMax &lt;/td&gt;&lt;td&gt; &lt;code&gt;0.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;RangeMax&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; &amp;gt; RangeMin &lt;/td&gt;&lt;td&gt; &lt;code&gt;1000.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;CorrectValue&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; Within [RangeMin, RangeMax] &lt;/td&gt;&lt;td&gt; &lt;code&gt;443.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;IllustrationUrl&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Valid URL or null &lt;/td&gt;&lt;td&gt; &lt;code&gt;https://media.example.com/...&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;FasitSource&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; Valid URL &lt;/td&gt;&lt;td&gt; &lt;code&gt;https://en.wikipedia.org/...&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;FasitCheckedAt&lt;/code&gt; &lt;/td&gt;&lt;td&gt; String &lt;/td&gt;&lt;td&gt; ISO date YYYY-MM-DD &lt;/td&gt;&lt;td&gt; &lt;code&gt;2024-10-01&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Answer Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; Example &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;UserMin&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; &amp;lt; UserMax &lt;/td&gt;&lt;td&gt; &lt;code&gt;380.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;UserMax&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; &amp;gt; UserMin &lt;/td&gt;&lt;td&gt; &lt;code&gt;450.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;Delta&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; Computed: UserMax - UserMin, &amp;gt;= 0 &lt;/td&gt;&lt;td&gt; &lt;code&gt;70.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;SubmittedAt&lt;/code&gt; &lt;/td&gt;&lt;td&gt; long &lt;/td&gt;&lt;td&gt; Epoch millis, &amp;gt; 0 &lt;/td&gt;&lt;td&gt; &lt;code&gt;1700000042000&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Score &amp;amp; Result Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; Example &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;Rank&lt;/code&gt; &lt;/td&gt;&lt;td&gt; int &lt;/td&gt;&lt;td&gt; &amp;gt;= 1 &lt;/td&gt;&lt;td&gt; &lt;code&gt;1&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;AverageResponseMs&lt;/code&gt; &lt;/td&gt;&lt;td&gt; long &lt;/td&gt;&lt;td&gt; &amp;gt;= 0, milliseconds &lt;/td&gt;&lt;td&gt; &lt;code&gt;8200&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;Percentage&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; 0.0 to 100.0 &lt;/td&gt;&lt;td&gt; &lt;code&gt;45.5&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;MedianDelta&lt;/code&gt; &lt;/td&gt;&lt;td&gt; double &lt;/td&gt;&lt;td&gt; &amp;gt;= 0 &lt;/td&gt;&lt;td&gt; &lt;code&gt;95.0&lt;/code&gt; &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Time Types&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Domain Type &lt;/th&gt;&lt;th&gt; Underlying &lt;/th&gt;&lt;th&gt; Constraints &lt;/th&gt;&lt;th&gt; Usage &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;EpochMillis&lt;/code&gt; &lt;/td&gt;&lt;td&gt; long &lt;/td&gt;&lt;td&gt; &amp;gt; 0 &lt;/td&gt;&lt;td&gt; Game start, countdown end, question start &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;DurationMs&lt;/code&gt; &lt;/td&gt;&lt;td&gt; long &lt;/td&gt;&lt;td&gt; &amp;gt;= 0 &lt;/td&gt;&lt;td&gt; Time remaining, response times &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;code&gt;DurationSeconds&lt;/code&gt; &lt;/td&gt;&lt;td&gt; int &lt;/td&gt;&lt;td&gt; &amp;gt; 0 &lt;/td&gt;&lt;td&gt; Question time limit &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Aggregate Summary&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Aggregate / Entity &lt;/th&gt;&lt;th&gt; Key Types &lt;/th&gt;&lt;th&gt; Mutable &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Game&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; GameId, GameCode, GameState, QuestionIndex, QuestionCount, TimeLimitSeconds, EpochMillis &lt;/td&gt;&lt;td&gt; Yes (aggregate root) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Player&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; PlayerId, PlayerName, PlayerEmail, GameCode &lt;/td&gt;&lt;td&gt; No (record) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Question&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; QuestionId, QuestionText, IsoUnit, RangeMin, RangeMax, CorrectValue &lt;/td&gt;&lt;td&gt; No (record) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;QuestionSet&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; QuestionSetId, List&amp;lt;Question&amp;gt; &lt;/td&gt;&lt;td&gt; No (record) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;Answer&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; QuestionId, PlayerId, UserMin, UserMax, SubmittedAt &lt;/td&gt;&lt;td&gt; No (record) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;ScoreResult&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; List&amp;lt;PlayerRanking&amp;gt;, ResponseTimeStat &lt;/td&gt;&lt;td&gt; No (record) &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; &lt;strong&gt;AnswerVisualization&lt;/strong&gt; &lt;/td&gt;&lt;td&gt; QuestionText, CorrectValue, Percentage, List&amp;lt;PlayerRange&amp;gt; &lt;/td&gt;&lt;td&gt; No (record) &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Implementation Notes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;All domain types should be Java records (immutable value objects)&lt;/li&gt;
&lt;li&gt;Validation happens in the record constructor (compact constructor)&lt;/li&gt;
&lt;li&gt;Adapters convert primitives to domain types at the boundary&lt;/li&gt;
&lt;li&gt;Game is the only mutable aggregate — uses SMC state machine for transitions&lt;/li&gt;
&lt;li&gt;Primitives are allowed in private helper methods within the domain layer&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>Tracking A Game For Insight With Umami</title>
      <link>https://www.inio.no/blog/2026-02-28-tracking-a-game-for-insight-with-umami.html</link>
      <pubDate>Sat, 28 Feb 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-02-28-tracking-a-game-for-insight-with-umami.html</guid>
      <description>
	&lt;h1&gt;Mental Model&lt;/h1&gt;
&lt;p&gt;For games, the best practice in Umami is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Track state transitions as events, not page views.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It’s a SPA-like structure&lt;/li&gt;
&lt;li&gt;No full reloads&lt;/li&gt;
&lt;li&gt;One URL represents many states&lt;/li&gt;
&lt;li&gt;We care about flow, not reloads&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Best Tracking Architecture&lt;/h1&gt;
&lt;h2&gt;Track Game Start&lt;/h2&gt;
&lt;p&gt;When game loads:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;umami.track(&apos;game-start&apos;, {
  game: &apos;Blue-Soaring-Phoenix&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The game names are currently random&lt;/p&gt;
&lt;h2&gt;Track State Transitions&lt;/h2&gt;
&lt;p&gt;Every time your state machine changes state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;umami.track(&apos;state-change&apos;, {
  game: &apos;Blue-Soaring-Phoenix&apos;,
  from: previousState,
  to: currentState
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or even better:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;umami.track(`state-${currentState}`, {
  game: &apos;Blue-Soaring-Phoenix&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now each state becomes measurable.&lt;/p&gt;
&lt;h2&gt;Track Important Events&lt;/h2&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;umami.track(&apos;level-complete&apos;, {
  game: &apos;Blue-Soaring-Phoenix&apos;,
  level: 3
});

umami.track(&apos;player-death&apos;, {
  game: &apos;Blue-Soaring-Phoenix&apos;,
  state: currentState
});

umami.track(&apos;game-finished&apos;, {
  game: &apos;Blue-Soaring-Phoenix&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;How To Build Funnels From This&lt;/h1&gt;
&lt;p&gt;Example funnel:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;game-start&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state-intro&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;state-battle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;level-complete&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;game-finished&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This tells you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Where players drop off&lt;/li&gt;
&lt;li&gt;Which states are bottlenecks&lt;/li&gt;
&lt;li&gt;Which states cause rage quit&lt;/li&gt;
&lt;li&gt;Conversion from start → finish&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Advanced (Recommended)&lt;/h1&gt;
&lt;p&gt;Since you are using:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/screen/{game-id}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should:&lt;/p&gt;
&lt;h3&gt;Extract game ID dynamically&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const gameId = window.location.pathname.split(&apos;/&apos;).pop();

umami.track(&apos;game-start&apos;, { game: gameId });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now your tracking automatically works for all games.&lt;/p&gt;
&lt;h1&gt;Even Better: Track State Duration&lt;/h1&gt;
&lt;p&gt;Add timestamps:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;let stateEnterTime = Date.now();

function onStateChange(newState) {
  const duration = Date.now() - stateEnterTime;

  umami.track(&apos;state-duration&apos;, {
    game: gameId,
    state: previousState,
    duration: duration
  });

  stateEnterTime = Date.now();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;The Cleanest Structure For tracking?&lt;/h1&gt;
&lt;p&gt;Given your architecture, I would track:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt; Event          &lt;/th&gt;&lt;th&gt; When                   &lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt; game-start     &lt;/td&gt;&lt;td&gt; Initial load           &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; state-enter    &lt;/td&gt;&lt;td&gt; Every state transition &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; state-exit     &lt;/td&gt;&lt;td&gt; Optional               &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; game-finished  &lt;/td&gt;&lt;td&gt; Win                    &lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt; game-abandoned &lt;/td&gt;&lt;td&gt; On unload (optional)   &lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;Goals&lt;/h1&gt;
&lt;h2&gt;Information I want:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Which states take too long&lt;/li&gt;
&lt;li&gt;Which states are skipped&lt;/li&gt;
&lt;li&gt;Engagement level&lt;/li&gt;
&lt;li&gt;Reconstruct full player paths, when do they fall off&lt;/li&gt;
&lt;li&gt;Detect loops&lt;/li&gt;
&lt;li&gt;Identify impossible transitions&lt;/li&gt;
&lt;li&gt;Find stuck states&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;I Do NOT want:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Track every tiny UI action&lt;/li&gt;
&lt;li&gt;Track animations&lt;/li&gt;
&lt;li&gt;Track every frame&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As this will flood our analytics.&lt;/p&gt;
&lt;h2&gt;Track:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Meaningful transitions&lt;/li&gt;
&lt;li&gt;Player intent&lt;/li&gt;
&lt;li&gt;Milestones&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Open questions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Do we want per-session tracking or anonymous only?&lt;/li&gt;
&lt;li&gt;What are the benefits of each approach?&lt;/li&gt;
&lt;li&gt;GDPR considerations.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Our Engineering Perspective: High Insight, Low Overhead&lt;/h1&gt;
&lt;p&gt;As we refine our tracking architecture for the game, our goal is to maximize the signal-to-noise ratio. We want to know exactly how players experience the game without bloating the client-side logic or flooding our database with trivial UI events.&lt;/p&gt;
&lt;h3&gt;1. Minimal Code Overhead via State Machine Hooks&lt;/h3&gt;
&lt;p&gt;Since the game already operates on a formal state machine, we don&apos;t need to sprinkle &lt;code&gt;umami.track&lt;/code&gt; calls throughout our UI components. Instead, we hook directly into the state transition logic. One single function call in the state machine&apos;s entry/exit handler covers the entire application. This keeps our domain logic clean and our tracking centralized.&lt;/p&gt;
&lt;h3&gt;2. Strategic Session Tracking vs. Pure Anonymity&lt;/h3&gt;
&lt;p&gt;We discussed the tradeoff between session-based and anonymous tracking.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Anonymous (Default):&lt;/strong&gt; Great for counting total events and seeing broad trends. This is our baseline and is 100% GDPR compliant out of the box because no PII or persistent identifiers are stored.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session-Based:&lt;/strong&gt; By utilizing Umami&apos;s built-in session handling (which uses a rotating hash based on IP, User Agent, and Website ID), we can reconstruct a single player&apos;s journey through the states without actually identifying &lt;em&gt;who&lt;/em&gt; they are. This is the &amp;quot;sweet spot&amp;quot; for us: it allows funnel analysis (Start → Level 1 → Finish) while remaining privacy-first. We aren&apos;t tracking people; we are tracking journeys.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. &amp;quot;Journey Replay&amp;quot; without the Data Weight&lt;/h3&gt;
&lt;p&gt;Instead of complex session recording tools, we use these events to build a &amp;quot;breadcrumb trail.&amp;quot; If we see a high frequency of &lt;code&gt;state-enter&lt;/code&gt; events for the same session without a corresponding &lt;code&gt;game-finished&lt;/code&gt;, we&apos;ve identified a drop-off point. If we see a session looping between two states, we&apos;ve found a UX friction point or a bug.&lt;/p&gt;
&lt;h3&gt;4. GDPR by Design&lt;/h3&gt;
&lt;p&gt;By hosting our own Umami instance and stripping out PII before it ever hits the wire, we ensure that our player data never leaves our infrastructure. Our &amp;quot;we&amp;quot; approach means we own the data, the tracking logic, and the responsibility to keep it anonymous.&lt;/p&gt;
&lt;h3&gt;Summary of our tracking strategy:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hook the State Machine:&lt;/strong&gt; Zero UI-level boilerplate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track Journeys, not Users:&lt;/strong&gt; Leverage Umami&apos;s anonymous session hashes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signal over Noise:&lt;/strong&gt; Focus on state transitions and milestones to keep the data actionable.&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>
    <item>
      <title>Proxmox Sdn Design</title>
      <link>https://www.inio.no/blog/2026-02-02-proxmox-sdn-virtualized-firewall-zones.html</link>
      <pubDate>Mon, 2 Feb 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-02-02-proxmox-sdn-virtualized-firewall-zones.html</guid>
      <description>
	&lt;h2&gt;Virtualized Firewall with Color-Coded Zones&lt;/h2&gt;
&lt;h2&gt;Physical Layout&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Internet (ISP)
      |
      | [Physical NIC 1 - RED/WAN]
      |
 ┌────▼────────────────────────────────────────┐
 │         Proxmox Host (4 NICs)               │
 │                                             │
 │  NIC1: enp1s0 (RED - WAN/Internet)         │
 │  NIC2: enp2s0 (Trunk - VLANs 10,20,30)     │
 │  NIC3: enp3s0 (Optional trunk/backup)       │
 │  NIC4: enp4s0 (Proxmox management)         │
 │                                             │
 │  ┌─────────────────────────────────────┐   │
 │  │   Proxmox SDN Configuration         │   │
 │  │                                     │   │
 │  │  Zone: RED    (no VLAN, direct)    │   │
 │  │  Zone: GREEN  (VLAN 10)            │   │
 │  │  Zone: ORANGE (VLAN 20)            │   │
 │  │  Zone: BLUE   (VLAN 30)            │   │
 │  └─────────────────────────────────────┘   │
 │                                             │
 │  ┌─────────────────────────────────────┐   │
 │  │  Firewall VM (IPFire/OPNsense)     │   │
 │  │                                     │   │
 │  │  vtnet0 → vmbr0 (RED)   [WAN]     │   │
 │  │  vtnet1 → vmbr10 (GREEN) [LAN]    │   │
 │  │  vtnet2 → vmbr20 (ORANGE) [DMZ]   │   │
 │  │  vtnet3 → vmbr30 (BLUE) [Wireless]│   │
 │  └─────────────────────────────────────┘   │
 │                                             │
 │  ┌─────────────┐  ┌─────────────┐          │
 │  │ Server VMs  │  │  DMZ VMs    │          │
 │  │ (GREEN)     │  │  (ORANGE)   │          │
 │  │ → vmbr10    │  │  → vmbr20   │          │
 │  └─────────────┘  └─────────────┘          │
 └─────────────────────────────────────────────┘
              |
              | [Physical NIC 2 - Trunk with VLANs]
              |
         ┌────▼────┐
         │ Managed │
         │ Switch  │
         │ (VLANs) │
         └────┬────┘
              |
    ┌─────────┼─────────┐
    |         |         |
 [VLAN 10] [VLAN 20] [VLAN 30]
  GREEN     ORANGE     BLUE
  Servers    DMZ      Wireless
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Proxmox Network Configuration&lt;/h2&gt;
&lt;h3&gt;Bridge Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# /etc/network/interfaces

# Management interface (Proxmox web UI access)
auto enp4s0
iface enp4s0 inet static
    address 10.1.2.10/24
    gateway 10.1.2.1

# RED Zone - WAN/Internet (dedicated, no VLAN)
auto vmbr0
iface vmbr0 inet manual
    bridge-ports enp1s0
    bridge-stp off
    bridge-fd 0
    bridge-vlan-aware no
    # This bridge is ONLY for firewall WAN interface

# VLAN-aware trunk bridge for internal zones
auto vmbr1
iface vmbr1 inet manual
    bridge-ports enp2s0
    bridge-stp off
    bridge-fd 0
    bridge-vlan-aware yes
    bridge-vids 10 20 30
    # Carries all internal VLANs

# GREEN Zone - Servers (VLAN 10)
auto vmbr10
iface vmbr10 inet manual
    bridge-ports enp2s0.10
    bridge-stp off
    bridge-fd 0
    # Proxmox host can have IP here for management
    # address 10.10.10.2/24

# ORANGE Zone - DMZ (VLAN 20)
auto vmbr20
iface vmbr20 inet manual
    bridge-ports enp2s0.20
    bridge-stp off
    bridge-fd 0

# BLUE Zone - Wireless (VLAN 30)
auto vmbr30
iface vmbr30 inet manual
    bridge-ports enp2s0.30
    bridge-stp off
    bridge-fd 0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Alternative: Using Proxmox SDN (Web UI)&lt;/h3&gt;
&lt;p&gt;Instead of manual bridge config, use Proxmox SDN:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Datacenter → SDN → Zones:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Zone: zone_red
  Type: Simple
  Bridge: vmbr0

Zone: zone_green
  Type: VLAN
  Bridge: vmbr1
  Tag: 10

Zone: zone_orange
  Type: VLAN
  Bridge: vmbr1
  Tag: 20

Zone: zone_blue
  Type: VLAN
  Bridge: vmbr1
  Tag: 30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Datacenter → SDN → VNets:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;VNet: red_wan
  Zone: zone_red

VNet: green_servers
  Zone: zone_green
  Subnet: 10.10.10.0/24
  Gateway: 10.10.10.1 (firewall)

VNet: orange_dmz
  Zone: zone_orange
  Subnet: 10.20.20.0/24
  Gateway: 10.20.20.1 (firewall)

VNet: blue_wireless
  Zone: zone_blue
  Subnet: 10.30.30.0/24
  Gateway: 10.30.30.1 (firewall)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Firewall VM Configuration&lt;/h2&gt;
&lt;h3&gt;VM Hardware Settings&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;VM ID: 100 (Firewall)

Hardware:
  - CPU: 2 cores (or more if doing IDS/IPS)
  - Memory: 2048 MB (4096 if using Suricata/IPS)
  - Disk: 20GB (thin provisioned)

Network Devices:
  net0: virtio, bridge=vmbr0 (RED/WAN)
  net1: virtio, bridge=vmbr10, tag=10 (GREEN)
  net2: virtio, bridge=vmbr20, tag=20 (ORANGE)
  net3: virtio, bridge=vmbr30, tag=30 (BLUE)

Options:
  - Start at boot: Yes
  - Start/Shutdown order: 1 (starts first)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Firewall Zone Mapping&lt;/h3&gt;
&lt;p&gt;Inside the firewall VM (IPFire example):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Interface Assignment:
  RED (WAN):       vtnet0 → vmbr0 → Internet
  GREEN (LAN):     vtnet1 → vmbr10 (VLAN 10) → Servers
  ORANGE (DMZ):    vtnet2 → vmbr20 (VLAN 20) → DMZ
  BLUE (Wireless): vtnet3 → vmbr30 (VLAN 30) → Wireless clients
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;IP Addressing Scheme&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;RED (WAN):        DHCP or static from ISP
GREEN (Servers):  10.10.10.0/24
  - Firewall:     10.10.10.1
  - Proxmox:      10.10.10.2
  - VMs:          10.10.10.10-254

ORANGE (DMZ):     10.20.20.0/24
  - Firewall:     10.20.20.1
  - Web server:   10.20.20.10
  - Mail server:  10.20.20.11

BLUE (Wireless):  10.30.30.0/24
  - Firewall:     10.30.30.1
  - DHCP range:   10.30.30.100-200

Management:       192.168.100.0/24
  - Proxmox:      192.168.100.10
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Traffic Flow Examples&lt;/h2&gt;
&lt;h3&gt;Internet Access from Server VM&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Server VM (10.10.10.50)
  ↓ via vmbr10 (VLAN 10)
Firewall GREEN interface (10.10.10.1)
  ↓ routing + NAT
Firewall RED interface
  ↓ via vmbr0
Physical NIC 1 (enp1s0)
  ↓
Internet
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;DMZ to Server Communication (blocked by default)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;DMZ VM (10.20.20.10)
  ↓ via vmbr20 (VLAN 20)
Firewall ORANGE interface (10.20.20.1)
  ↓ [FIREWALL RULES - usually DENY]
Firewall GREEN interface (10.10.10.1)
  ↓ via vmbr10 (VLAN 10)
Server VM (10.10.10.50)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Wireless Client to Internet&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Wireless device (10.30.30.150)
  ↓ via Access Point (VLAN 30 tagged)
Physical Switch (VLAN 30)
  ↓ via trunk
Physical NIC 2 (enp2s0, VLAN 30)
  ↓ via vmbr30
Firewall BLUE interface (10.30.30.1)
  ↓ routing + NAT
Firewall RED interface
  ↓
Internet
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;External Switch Configuration&lt;/h2&gt;
&lt;p&gt;Your managed switch needs VLAN configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Port 1: Trunk (to Proxmox NIC 2)
  - Tagged VLANs: 10, 20, 30
  - Untagged: none

Port 2-10: GREEN (Servers)
  - Untagged VLAN: 10

Port 11-15: ORANGE (DMZ)
  - Untagged VLAN: 20

Port 16-24: Access Points
  - Tagged VLAN: 30 (wireless clients)
  - Or untagged VLAN 30 if AP doesn&apos;t do VLANs
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;VM Deployment Example&lt;/h2&gt;
&lt;h3&gt;Web Server in DMZ&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;VM ID: 200 (Web Server)

Hardware:
  - CPU: 2 cores
  - Memory: 2048 MB
  - Disk: 50GB

Network:
  net0: virtio, bridge=vmbr20, tag=20

Inside VM:
  IP: 10.20.20.10/24
  Gateway: 10.20.20.1 (firewall)
  DNS: 10.10.10.53 (if you have internal DNS)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Firewall rules needed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ORANGE → RED: Allow HTTP/HTTPS outbound
RED → ORANGE: Allow HTTP/HTTPS to 10.20.20.10 (port forward)
ORANGE → GREEN: Deny (except specific DB server on specific port)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Database Server on GREEN&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;VM ID: 201 (Database Server)

Network:
  net0: virtio, bridge=vmbr10, tag=10

Inside VM:
  IP: 10.10.10.20/24
  Gateway: 10.10.10.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Firewall rules:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ORANGE (10.20.20.10) → GREEN (10.10.10.20:3306): Allow
GREEN → Any: Default allow to internet
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;High Availability Considerations&lt;/h2&gt;
&lt;p&gt;If you want redundancy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Proxmox Node 1:                Proxmox Node 2:
NIC1 → RED                     NIC1 → RED (failover)
NIC2 → Trunk (active)          NIC2 → Trunk (standby)
NIC3 → Cluster link            NIC3 → Cluster link
NIC4 → Management              NIC4 → Management

Firewall VM with HA enabled (Proxmox HA manager)
Shared storage (Ceph or NFS) for VM disks
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Security Notes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Proxmox management network&lt;/strong&gt; (an RFC1918 range on its own VLAN) should be on GREEN or separate VLAN&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firewall VM gets CPU pinning&lt;/strong&gt; for better performance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable virtio&lt;/strong&gt; for all network interfaces (better performance)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disable MAC learning&lt;/strong&gt; on bridges if having issues&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Log firewall rules&lt;/strong&gt; to monitor traffic between zones&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regular backups&lt;/strong&gt; of firewall VM configuration&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Migration from Physical Firewall&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Document current setup&lt;/strong&gt; (rules, port forwards, DHCP, DNS)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy firewall VM&lt;/strong&gt; with RED on separate test network&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Configure GREEN zone&lt;/strong&gt; first, test connectivity&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add ORANGE and BLUE&lt;/strong&gt;, verify isolation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Migrate rules&lt;/strong&gt; one zone at a time&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test thoroughly&lt;/strong&gt; before switching production traffic&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep old firewall&lt;/strong&gt; as backup for a few weeks&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Performance Considerations&lt;/h2&gt;
&lt;p&gt;With 4 NICs and virtualized firewall:&lt;br /&gt;
- &lt;strong&gt;Expected throughput&lt;/strong&gt;: 1-2 Gbps (with virtio)&lt;br /&gt;
- &lt;strong&gt;Latency&lt;/strong&gt;: ~1ms additional vs physical&lt;br /&gt;
- &lt;strong&gt;CPU usage&lt;/strong&gt;: ~5-15% per core under load&lt;/p&gt;
&lt;p&gt;If you need 10G or more:&lt;br /&gt;
- Consider &lt;strong&gt;PCI passthrough&lt;/strong&gt; of NIC to firewall VM&lt;br /&gt;
- Or use &lt;strong&gt;SR-IOV&lt;/strong&gt; for near-native performance&lt;/p&gt;

      </description>
    </item>
    <item>
      <title>Header Of INIO</title>
      <link>https://www.inio.no/blog/2026-02-02-header-of-inio.html</link>
      <pubDate>Mon, 2 Feb 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-02-02-header-of-inio.html</guid>
      <description>
	&lt;h1&gt;Header of INIO&lt;/h1&gt;
&lt;p&gt;This article is related to my previous beloved &lt;a href=&quot;https://jaspervdj.be/hakyll/&quot;&gt;Hakyll&lt;/a&gt; site. Unfortunately maintaining a number of ecosystems (&lt;em&gt;despite my love for them all&lt;/em&gt; ) takes its toll. I therefore consolidate on &lt;a href=&quot;https://jbake.org/&quot;&gt;JBake&lt;/a&gt; for now.&lt;/p&gt;
&lt;p&gt;Still most of this remains true.&lt;/p&gt;
&lt;h2&gt;Metainfo&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;
title=Header Of INIO
date=2026-02-02
type=post
status=published
tag = hakyll blog structure
level = low
lang = en
description =This is a description!
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Title&lt;/h3&gt;
&lt;p&gt;Title of the document replace first line #Header&lt;br /&gt;
Reflected in this documents filename along with date, This is also the base for any static links.&lt;/p&gt;
&lt;h3&gt;Author:&lt;/h3&gt;
&lt;p&gt;Name of person/institution responsible for the content.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;author=me
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Category:&lt;/h3&gt;
&lt;p&gt;A way to maintain a number of informations channels.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;public:&lt;/strong&gt; anyone&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;group:&lt;/strong&gt; a named group inio&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;private:&lt;/strong&gt; the author&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Level&lt;/h3&gt;
&lt;p&gt;In order to keep up with GDPR, this information should probably reflect values, policies and national guidelines.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;high, medium,  low&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;level=medium
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Tags&lt;/h3&gt;
&lt;p&gt;Meta information, for sorting and grouping.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tag=medium GDPR ISO-27001
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Language&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;If multi-language-publishing is set up&lt;/li&gt;
&lt;li&gt;&lt;em&gt;no,en,de&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lang=no
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Date&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;When to publish the article&lt;/li&gt;
&lt;li&gt;ISO date format: YYYY-MM-DD&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;date = 2022-02-02
&lt;/code&gt;&lt;/pre&gt;

      </description>
    </item>
    <item>
      <title>Note Pipeline Design</title>
      <link>https://www.inio.no/blog/2026-01-01-note-pipeline-design.html</link>
      <pubDate>Thu, 1 Jan 2026 00:00:00 +0000</pubDate>
      <guid isPermaLink="false">blog/2026-01-01-note-pipeline-design.html</guid>
      <description>
	&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;A single-source, multi-target publishing system that maintains notes in plain markdown with YAML frontmatter and routes them to different outputs based on metadata.&lt;/p&gt;
&lt;h2&gt;Architecture&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;
Source Repository
├── posts/
│   ├── session-with-claude.md
│   ├── project-notes.md
│   └── research-paper.md
├── img/
│   └── diagrams/
├── data/
│   ├── tables.csv
│   └── figures.tex
└── pipeline/
    ├── Makefile
    ├── build.kt (or build.py)
    └── templates/
        ├── pdf.tex
        ├── web.html
        └── slides.html

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Metadata-Driven Routing&lt;/h2&gt;
&lt;p&gt;Your frontmatter determines the pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;title: Session with claude
author: jensse
category: private      # routes to: private blog, encrypted PDF
level: low            # affects: visibility, indexing
tags: ai code explore # used for: cross-linking, search
lang: en
date: 2025-02-21
output:
  - web-private       # target: personal site (authenticated)
  - pdf-archive       # target: versioned PDF in archive/
format: article       # template: article vs slides vs paper
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Pipeline Stages&lt;/h2&gt;
&lt;h3&gt;Stage 1: Parse &amp;amp; Route&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.kt (pseudo-code)
fun processNote(file: Path) {
    val (frontmatter, content) = parseMarkdown(file)

    val targets = when {
        frontmatter.category == &amp;quot;private&amp;quot; &amp;amp;&amp;amp;
        frontmatter.level == &amp;quot;low&amp;quot; -&amp;gt;
            listOf(Target.PrivateWeb, Target.PDFArchive)

        frontmatter.category == &amp;quot;public&amp;quot; -&amp;gt;
            listOf(Target.PublicWeb, Target.PDF)

        frontmatter.format == &amp;quot;paper&amp;quot; -&amp;gt;
            listOf(Target.LaTeXPaper, Target.PDF)

        else -&amp;gt; listOf(Target.DefaultWeb)
    }

    targets.forEach { buildForTarget(it, file, frontmatter) }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Stage 2: Data Processing&lt;/h3&gt;
&lt;p&gt;For notes with embedded data/figures:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;posts/research-paper.md
  ↓ (references data/experiment.csv)
  ↓
pipeline/processors/csv-to-table.kt
  ↓ (generates LaTeX table or SVG chart)
  ↓
build/intermediates/experiment-table.tex
  ↓
[included in final document]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Stage 3: Format Conversion&lt;/h3&gt;
&lt;p&gt;Using Pandoc as the universal converter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# For web output
pandoc post.md \
  --template=templates/web.html \
  --filter=process-images.py \
  --css=styles.css \
  -o output/web/post.html

# For PDF via LaTeX
pandoc post.md \
  --template=templates/pdf.tex \
  --filter=process-latex.py \
  --pdf-engine=xelatex \
  -o output/pdf/post.pdf

# For slides
pandoc post.md \
  --template=templates/slides.html \
  -t revealjs \
  -o output/slides/post.html
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Stage 4: Post-Processing&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Copy images to output directories&lt;/li&gt;
&lt;li&gt;Generate index/TOC pages&lt;/li&gt;
&lt;li&gt;Create RSS feeds&lt;/li&gt;
&lt;li&gt;Update search index&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Makefile Structure&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-makefile&quot;&gt;# Variables
SRC_DIR := posts
IMG_DIR := img
OUT_DIR := output
BUILD_DIR := build

# Targets
all: web pdf

web: $(patsubst $(SRC_DIR)/%.md,$(OUT_DIR)/web/%.html,$(wildcard $(SRC_DIR)/*.md))

pdf: $(patsubst $(SRC_DIR)/%.md,$(OUT_DIR)/pdf/%.pdf,$(wildcard $(SRC_DIR)/*.md))

# Pattern rules
$(OUT_DIR)/web/%.html: $(SRC_DIR)/%.md
	@mkdir -p $(OUT_DIR)/web
	kotlin build.kt web $&amp;lt;

$(OUT_DIR)/pdf/%.pdf: $(SRC_DIR)/%.md
	@mkdir -p $(OUT_DIR)/pdf
	kotlin build.kt pdf $&amp;lt;

clean:
	rm -rf $(OUT_DIR) $(BUILD_DIR)

watch:
	fswatch -o $(SRC_DIR) $(IMG_DIR) | xargs -n1 -I{} make all

.PHONY: all web pdf clean watch
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Example Build Script (Kotlin)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;
// build.kt
import java.nio.file.Path
import kotlin.io.path.*

data class Frontmatter(
    val title: String,
    val category: String,
    val level: String,
    val output: List&amp;lt;String&amp;gt;,
    val format: String = &amp;quot;article&amp;quot;
)

enum class Target {
    PrivateWeb, PublicWeb, PDF, LaTeXPaper
}

fun main(args: Array&amp;lt;String&amp;gt;) {
    val command = args[0] // &amp;quot;web&amp;quot; or &amp;quot;pdf&amp;quot;
    val sourceFile = Path(args[1])

    val (frontmatter, content) = parseMarkdownFile(sourceFile)

    when (command) {
        &amp;quot;web&amp;quot; -&amp;gt; buildWebOutput(sourceFile, frontmatter, content)
        &amp;quot;pdf&amp;quot; -&amp;gt; buildPDFOutput(sourceFile, frontmatter, content)
    }
}

fun buildWebOutput(source: Path, meta: Frontmatter, content: String) {
    val templatePath = selectTemplate(meta.category, meta.format, &amp;quot;web&amp;quot;)

    executeCommand(
        &amp;quot;pandoc&amp;quot;, source.toString(),
        &amp;quot;--template=$templatePath&amp;quot;,
        &amp;quot;--filter=filters/web-images.py&amp;quot;,
        &amp;quot;-o&amp;quot;, outputPath(source, &amp;quot;web&amp;quot;, &amp;quot;html&amp;quot;)
    )
}

fun buildPDFOutput(source: Path, meta: Frontmatter, content: String) {
    // Preprocess: extract LaTeX snippets, CSV data
    val processed = preprocessForLaTeX(source, content)

    // Convert to PDF via LaTeX
    executeCommand(
        &amp;quot;pandoc&amp;quot;, processed.toString(),
        &amp;quot;--template=templates/pdf.tex&amp;quot;,
        &amp;quot;--pdf-engine=xelatex&amp;quot;,
        &amp;quot;-o&amp;quot;, outputPath(source, &amp;quot;pdf&amp;quot;, &amp;quot;pdf&amp;quot;)
    )
}

fun selectTemplate(category: String, format: String, outputType: String): String =
    when {
        format == &amp;quot;paper&amp;quot; -&amp;gt; &amp;quot;templates/academic-paper.$outputType&amp;quot;
        category == &amp;quot;private&amp;quot; -&amp;gt; &amp;quot;templates/private-note.$outputType&amp;quot;
        else -&amp;gt; &amp;quot;templates/default.$outputType&amp;quot;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Data Processing Pipeline&lt;/h2&gt;
&lt;p&gt;For your LaTeX/CSV workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;data/experiment.csv
  ↓
pipeline/processors/CsvToLatexTable.kt
  ↓
build/intermediates/experiment-table.tex

data/diagram.tex (TikZ)
  ↓
pipeline/processors/LatexToSvg.kt
  ↓
build/intermediates/diagram.svg
  ↓ (for web)
img/generated/diagram.svg
  ↓ (for PDF, keep as .tex)
[embedded directly in LaTeX]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Key Advantages&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Single source of truth&lt;/strong&gt;: All notes in &lt;code&gt;posts/*.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version control friendly&lt;/strong&gt;: Plain text, Git-friendly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metadata-driven&lt;/strong&gt;: Routing logic in frontmatter, not file locations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Composable&lt;/strong&gt;: Each processor is a small, testable function&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incremental builds&lt;/strong&gt;: Make only rebuilds changed files&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Type-safe&lt;/strong&gt;: Kotlin gives you compile-time safety for your build logic&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No hidden magic&lt;/strong&gt;: You control every transformation&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Migration Path&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Start simple&lt;/strong&gt;: Get basic markdown → HTML working with Pandoc&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add routing&lt;/strong&gt;: Implement metadata parsing and target selection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add formats&lt;/strong&gt;: One at a time (PDF, then slides, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add preprocessing&lt;/strong&gt;: CSV conversion, LaTeX snippets as needed&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimize&lt;/strong&gt;: Caching, parallel builds when it matters&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Example Workflow&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Write a note
vim posts/new-idea.md

# Build everything
make all

# Or build specific target
make web
make pdf

# Watch for changes
make watch

# Deploy to destinations
make deploy-private  # rsync to private server
make deploy-public   # push to GitHub Pages
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Tools You&apos;ll Need&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pandoc&lt;/strong&gt;: Universal document converter&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make&lt;/strong&gt;: Build orchestration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kotlin&lt;/strong&gt; (or Python): Custom processing scripts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XeLaTeX&lt;/strong&gt;: PDF generation from LaTeX&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ImageMagick/Inkscape&lt;/strong&gt;: Image format conversions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fswatch&lt;/strong&gt;: File watching for auto-rebuild&lt;/li&gt;
&lt;/ul&gt;

      </description>
    </item>

  </channel>
</rss>