<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Gideon Sigilai — Writing</title>
    <link>https://gideonsigilai.github.io/writing</link>
    <atom:link href="https://gideonsigilai.github.io/rss.xml" rel="self" type="application/rss+xml" />
    <description>Gideon Sigilai is a software engineer in Nairobi, Kenya, building thoughtful mobile and web products with Flutter, Go, Firebase, Rust, and on-device machine learning.</description>
    <language>en</language>
    <lastBuildDate>Wed, 21 Oct 2026 06:15:00 +0000</lastBuildDate>
    <managingEditor>gideon.sigilai3000@gmail.com (Gideon Sigilai)</managingEditor>
    <item>
      <title>The browser should not hold the keys</title>
      <link>https://gideonsigilai.github.io/writing/the-browser-should-not-hold-the-keys</link>
      <guid isPermaLink="true">https://gideonsigilai.github.io/writing/the-browser-should-not-hold-the-keys</guid>
      <pubDate>Wed, 21 Oct 2026 06:15:00 +0000</pubDate>
      <description>How this site works: one Rust binary serving the pages, argon2 sessions in httpOnly cookies, double-submit CSRF, and markdown sanitised before it is stored.</description>
      <category>Rust</category>
      <category>Security</category>
      <content:encoded><![CDATA[<p>Every form on a public site is an invitation. This one accepts messages, so I built it the way I would build a client project: everything that matters happens on the server.</p>
<h3>One process, no public API</h3>
<p>The site, the admin dashboard, and the data all live in a single Rust binary. Pages are rendered as HTML on the server, so there is no client-side data fetching to secure and no API surface exposed to the internet.</p>
<pre><code>browser ──(same-origin, httpOnly cookie)──► Rust server ──► SQLite
</code></pre>
<p>There is no CORS configuration to get wrong because there is nothing cross-origin to talk to.</p>
<h3>Sessions JavaScript cannot read</h3>
<p>Sign-in produces a random token stored in an httpOnly, SameSite=Strict cookie. The client bundle never sees it. Mutations also need a CSRF value echoed from a second cookie, checked in constant time against the session on the server.</p>
<pre><code>// Double submit, compared without an early exit.
let ok = bool::from(session.csrf.as_bytes().ct_eq(supplied.as_bytes()));
</code></pre>
<p>Passwords are hashed with argon2 on first boot and the plaintext is never stored. Login attempts are rate limited more tightly than the contact form, because guessing a password is a more expensive failure than one extra message.</p>
<h3>Rendering untrusted input</h3>
<p>Post bodies are markdown written by an authenticated owner, but they still go through <code>pulldown-cmark</code> and then <code>ammonia</code>. Nothing to gain from trusting your own database, everything to lose if an import path ever changes.</p>
<ul>
<li>Raw HTML in markdown is stripped.</li>
<li>Script URLs never survive sanitisation.</li>
<li>Every rendered page carries a restrictive content security policy.</li>
</ul>
<h3>What this buys</h3>
<ul>
<li>No keys in the browser.</li>
<li>No public API to probe.</li>
<li>Injected content cannot execute scripts.</li>
<li>One deployable file: copy it next to a SQLite database and run it.</li>
</ul>
<p>The pattern is not novel; it is just rarely finished. Most tutorials stop at “and then we fetch the data”. The work is in the parts that fail quietly — constant-time compares, sanitised output, and cookies the client cannot touch.</p>
]]></content:encoded>
    </item>
    <item>
      <title>State that does not fight you</title>
      <link>https://gideonsigilai.github.io/writing/state-that-does-not-fight-you</link>
      <guid isPermaLink="true">https://gideonsigilai.github.io/writing/state-that-does-not-fight-you</guid>
      <pubDate>Fri, 18 Sep 2026 07:00:00 +0000</pubDate>
      <description>A practical way to keep Flutter screens honest: one source of truth, sealed UI state, and tests that describe behaviour instead of widgets.</description>
      <category>Flutter</category>
      <category>Architecture</category>
      <content:encoded><![CDATA[<p>Most Flutter codebases do not break because of a missing animation. They break because three layers disagree about what is true.</p>
<h3>The symptom</h3>
<p>A screen shows a loading spinner while the data is already there. A button submits twice because the guard lives in the widget. A logged-out user briefly sees a balance. None of these are exotic bugs; they are all the same bug wearing different clothes: <strong>state with more than one owner</strong>.</p>
<h3>Pick one owner</h3>
<p>In OneKitty I kept a single controller per feature slice. The widget layer reads, the controller decides, the repository fetches. If something needs to change, it travels one way:</p>
<pre><code>abstract class WalletState {
  const WalletState();
}

class WalletLoading extends WalletState {
  const WalletLoading();
}

class WalletReady extends WalletState {
  const WalletReady(this.balance, this.entries);
  final int balance;
  final List&lt;Entry&gt; entries;
}

class WalletFailed extends WalletState {
  const WalletFailed(this.message);
  final String message;
}
</code></pre>
<p>Sealed states are boring on purpose. The compiler forces every <code>switch</code> to answer every case, so an unhandled failure is a build error instead of a blank screen in Nairobi traffic.</p>
<h3>Guard actions in the controller, not the widget</h3>
<p>A double submit is not a UI problem. It is a missing invariant.</p>
<pre><code>Future&lt;void&gt; send() async {
  if (_state is! WalletReady || _sending) return;
  _sending = true;
  try {
    await repository.send(_amount);
    emit(repository.fetch()); // reload from the source of truth
  } finally {
    _sending = false;
  }
}
</code></pre>
<p>The button can still be disabled for feedback, but correctness does not depend on it.</p>
<h3>Test behaviour, not trees</h3>
<p>Widget tests that assert on <code>find.byType</code> break every redesign. Tests that assert on what the user can do survive:</p>
<pre><code>testWidgets('a pending contribution blocks a second one', (tester) async {
  await tester.pumpWidget(App(fixture: delayedRepository()));
  await tester.tap(find.text('Contribute'));
  await tester.pump();
  expect(find.text('Sending…'), findsOneWidget);
  expect(find.text('Contribute'), findsNothing);
});
</code></pre>
<h3>What I look for in review</h3>
<ul>
<li>Every screen has exactly one state object.</li>
<li>Loading, ready, empty, and failed are all representable.</li>
<li>Mutations re-read from the source of truth instead of patching local copies.</li>
<li>Errors are values with user-facing copy, not raw exceptions rendered into text.</li>
</ul>
<p>That is the whole trick. Nothing clever — just making it impossible for the app to be in a state you never designed.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Plant disease detection, on the device</title>
      <link>https://gideonsigilai.github.io/writing/plant-disease-on-device</link>
      <guid isPermaLink="true">https://gideonsigilai.github.io/writing/plant-disease-on-device</guid>
      <pubDate>Sun, 2 Aug 2026 09:30:00 +0000</pubDate>
      <description>Why an on-device image classifier beats a cloud round trip for a farmer in a low-signal area — and the export pipeline that makes it small enough to ship.</description>
      <category>Machine Learning</category>
      <category>Flutter</category>
      <category>TFLite</category>
      <content:encoded><![CDATA[<p>A farmer standing in a field with two bars of signal does not care about your server’s GPU. The interesting constraint in the Plant Disease Detector was never accuracy on a benchmark — it was latency, privacy, and offline behaviour.</p>
<h3>Start from the deployment target</h3>
<p>Before training, I wrote down the budget: the model has to run in well under a second on a mid-range Android phone, fit inside a normal app download, and never upload a photo to a server.</p>
<ul>
<li>Input resolution: small enough for real-time inference.</li>
<li>Quantisation: post-training int8 where accuracy held.</li>
<li>Class set: the 38 PlantVillage classes, grouped so an ambiguous prediction can still say “likely fungal”.</li>
</ul>
<h3>Export, then verify the export</h3>
<p>The model that wins on a validation split is not always the model that ships. A quantised model has different numerical behaviour, so compare the two:</p>
<pre><code>interpreter = tf.lite.Interpreter(model_path="model.tflite")
interpreter.allocate_tensors()
# Compare argmax on a labelled sample of 200 images,
# not on the training set you already overfit to.
</code></pre>
<p>If quantisation moves accuracy by more than a point, narrow the input range or keep float16 for the last layers instead of giving up the speed.</p>
<h3>The app is the product</h3>
<p>The Flutter side gets the interesting work: camera capture with a crop guide, a queue for offline analysis, and a result screen that leads with a <em>next action</em> instead of a label.</p>
<ul>
<li>Confidence under 70%? Ask for a second photo rather than guessing.</li>
<li>The advice copy matters more than the percentage.</li>
<li>Store scans locally with a timestamp so a farmer can compare over a season.</li>
</ul>
<h3>What I would do next</h3>
<ul>
<li>On-device fine-tuning on the farm’s own leaves.</li>
<li>A disease-class weighting loss so the rare classes are not drowned by healthy leaf images.</li>
<li>A tiny language model for local-language guidance, generated from a reviewed template.</li>
</ul>
<p>The lesson generalises: for a used product, the optimiser is the user’s environment, not the leaderboard.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
