<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://garethstokes.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://garethstokes.github.io/" rel="alternate" type="text/html" /><updated>2026-06-17T14:25:01+00:00</updated><id>https://garethstokes.github.io/feed.xml</id><title type="html">Gareth Stokes</title><subtitle>Building to learn, in the open</subtitle><author><name>Gareth Stokes</name></author><entry><title type="html">the math under manifest</title><link href="https://garethstokes.github.io/the-math-under-manifest/" rel="alternate" type="text/html" title="the math under manifest" /><published>2026-06-17T00:00:00+00:00</published><updated>2026-06-17T00:00:00+00:00</updated><id>https://garethstokes.github.io/the-math-under-manifest</id><content type="html" xml:base="https://garethstokes.github.io/the-math-under-manifest/"><![CDATA[<p>This is how to define a user in manifest, a database library for Haskell:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">UserT</span> <span class="n">f</span> <span class="o">=</span> <span class="kt">User</span>
  <span class="p">{</span> <span class="n">userId</span>    <span class="o">::</span> <span class="kt">Field</span> <span class="n">f</span> <span class="p">(</span><span class="kt">Pk</span> <span class="kt">Int</span><span class="p">)</span>
  <span class="p">,</span> <span class="n">userName</span>  <span class="o">::</span> <span class="kt">Field</span> <span class="n">f</span> <span class="kt">Text</span>
  <span class="p">,</span> <span class="n">userEmail</span> <span class="o">::</span> <span class="kt">Field</span> <span class="n">f</span> <span class="p">(</span><span class="kt">Nullable</span> <span class="kt">Text</span><span class="p">)</span>
  <span class="p">}</span>
</code></pre></div></div>

<p>and this is the type of the function that loads one together with its posts:</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">with</span>
  <span class="o">::</span> <span class="kt">HasRelation</span> <span class="n">a</span> <span class="n">name</span>
  <span class="o">=&gt;</span> <span class="kt">Strategy</span> <span class="n">name</span>
  <span class="o">-&gt;</span> <span class="kt">Ent</span> <span class="n">l</span> <span class="n">a</span>
  <span class="o">-&gt;</span> <span class="kt">Db</span> <span class="p">(</span><span class="kt">Ent</span> <span class="p">(</span><span class="kt">Insert</span> <span class="n">name</span> <span class="n">l</span><span class="p">)</span> <span class="n">a</span><span class="p">)</span>
</code></pre></div></div>

<p>That can seem pretty strange, but hopefully it will all make sense by the time you have read
this. We build up to it from simple maths, one step at a time, so that each piece makes
sense before the next one arrives.</p>

<figure>
  <img src="/assets/images/hero-the-climb.svg" alt="A ladder whose rungs from bottom to top read functions and composition, functors, higher-kinded types, applicative and profunctor, monads, and type families, with an arrow climbing toward the top where the opening signature becomes readable." />
  <figcaption>From functions to type families, one rung at a time, until the opening signature reads itself.</figcaption>
</figure>

<p>Most writing about code like this goes one of two ways. It walks the syntax token by token,
which leaves you able to recite the type but not to write it; or it waves a hand and calls
the whole thing category theory, which leaves you with less. This series does what a maths
teacher asks for instead, and shows the working. Functions first, then functors, then
higher-kinded types, then the codecs and monads and type-level machinery stacked on top,
each one earned before the next is introduced.</p>

<p>There is a reason the working is worth showing, and it is the reason manifest exists.
Haskell has had excellent type-safe SQL libraries for years, but it never had the thing
SQLAlchemy gives Python: a session you hand an edited object to, which works out the minimal
UPDATE for whatever you changed. Nobody built it because the obvious design mutates the
object in place so the session can watch it, and a Haskell value will not be mutated. So
every idea that follows is a piece of mathematics standing in for a mutation you are not
allowed to make. Take away the ability to reach into a value and change it, and category
theory is what you reach for to get the same work done honestly.</p>

<p>If Haskell itself is new to you, the <a href="/the-math-under-manifest/prelude/">prelude</a> covers how to read the
type signatures first. The path then runs from a plain record to that signature, in six
steps:</p>

<ol>
  <li><a href="/the-math-under-manifest/the-call/">a record that’s three things at once</a> — one declaration that is a table, a row, and a query (functor and higher-kinded types)</li>
  <li><a href="/the-math-under-manifest/the-threshold/">making the markers disappear</a> — getting a clean <code class="language-plaintext highlighter-rouge">Int</code> back (the Identity functor)</li>
  <li><a href="/the-math-under-manifest/the-trials/">reading a row without a ceiling</a> — decode and encode from one description (applicative and profunctor)</li>
  <li><a href="/the-math-under-manifest/the-ordeal/">the session that can’t see your edit</a> — change tracking on a value you cannot mutate (the monad and the snapshot-diff)</li>
  <li><a href="/the-math-under-manifest/the-summit/">a shadow in the world of types</a> — a missing relation as a compile error (type-level functions)</li>
  <li><a href="/the-math-under-manifest/the-return/">the working, complete</a> — the signature, now obvious (Generics ties it together)</li>
</ol>]]></content><author><name>Gareth Stokes</name></author><summary type="html"><![CDATA[The category theory behind manifest, a Haskell ORM, built up from first principles one step at a time.]]></summary></entry><entry><title type="html">I deleted the dependency solver</title><link href="https://garethstokes.github.io/deleting-the-dependency-solver.html" rel="alternate" type="text/html" title="I deleted the dependency solver" /><published>2026-06-16T00:00:00+00:00</published><updated>2026-06-16T00:00:00+00:00</updated><id>https://garethstokes.github.io/deleting-the-dependency-solver</id><content type="html" xml:base="https://garethstokes.github.io/deleting-the-dependency-solver.html"><![CDATA[<p>Every package manager has a component its users dread. In Haskell it’s the version solver: the part of cabal-install that takes the version ranges scattered across your dependency graph and searches for one assignment that satisfies all of them. When it works you never think about it. When it fails you get a page of text about incompatible bounds on a package three levels down that you’ve never heard of, and your afternoon is gone.</p>

<p><a href="https://github.com/garethstokes/zinc">zinc</a> is a Haskell build tool that doesn’t have one. It started as a question I wanted a real answer to: how far can you get without a solver at all? Not “a faster solver” or “a friendlier solver” but none, and see where that runs out.</p>

<p>So the whole design rests on a single decision. zinc is git-native and Nix-assisted, and it drops the solver entirely. Dependencies are content-addressed sources: usually a git repo at a pinned rev, with explicit vendored Hackage tarballs for the few packages that have no git repo. Each package name resolves to exactly one ref across the workspace. Nothing searches a constraint space, so there are no solver errors. The price is that a real conflict between two packages doesn’t get resolved for you. You resolve it once, by hand, and the choice gets frozen into the lockfile. I think that trade is worth making, and most of this post is me checking how far it actually goes before it breaks.</p>

<figure>
  <img src="/assets/images/hero-solver-vs-chain.svg" alt="On the left, a tangle of dependency nodes with version ranges and a question mark, labelled cabal: search for an assignment. On the right, a clean vertical chain of name-to-commit-hash boxes, labelled zinc: one pinned commit each." />
  <figcaption>cabal searches for a version assignment; zinc pins a commit and freezes it.</figcaption>
</figure>

<p>One other thing shaped the work, and I’ll mostly keep it in the background: zinc was built alongside an agent, with every commit tied to a tracked issue and the design written down before the code. What that was good for had less to do with generating code than with keeping the no-solver rule honest across a couple hundred commits. The stories below are where that pressure shows up.</p>

<h2 id="where-zinc-sits">Where zinc sits</h2>

<p>This isn’t unprecedented. zinc takes one more step down a road Go already walked.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Source of packages</th>
      <th>Pinned by</th>
      <th>Selection algorithm</th>
      <th>Multiple versions of one package?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cargo</td>
      <td>central registry (crates.io)</td>
      <td>hash in <code class="language-plaintext highlighter-rouge">Cargo.lock</code></td>
      <td>full semver solver</td>
      <td>yes, incompatible majors coexist</td>
    </tr>
    <tr>
      <td>Go</td>
      <td>decentralized git modules</td>
      <td>hash in <code class="language-plaintext highlighter-rouge">go.sum</code></td>
      <td>Minimal Version Selection</td>
      <td>no, one per module</td>
    </tr>
    <tr>
      <td>zinc</td>
      <td>decentralized git</td>
      <td>hash in <code class="language-plaintext highlighter-rouge">zinc.lock</code></td>
      <td>none</td>
      <td>no, one ref, ever</td>
    </tr>
  </tbody>
</table>

<p>Cargo is the maximalist version. It runs a real solver over semver ranges, and the same crate can appear several times in one build at different major versions. Go is the interesting middle: git-native, hash-pinned, no SAT solver, but it keeps a selection algorithm called Minimal Version Selection that computes the lowest version of each module compatible with everyone’s stated minimums. MVS isn’t a solver in Cargo’s sense, but it still chooses versions for you.</p>

<p>zinc drops that last piece too. No algorithm picks versions. The rule is blunt: one ref per package name, a root-level override wins any disagreement, and otherwise the most recently referenced ref does. A tag like <code class="language-plaintext highlighter-rouge">v2.2.3.0</code> is just how you pick a commit the first time you add a dependency. Once it’s written to <code class="language-plaintext highlighter-rouge">zinc.lock</code> it’s a SHA plus a content hash, and the version string has done its only job. That’s the entire resolution model.</p>

<p>The fair question is what happens when real packages don’t cooperate, so that’s where I’ll start.</p>

<h2 id="argument-1-no-solver-survives-reality-because-versions-describe-but-never-decide">Argument 1: no solver survives reality, because versions describe but never decide</h2>

<p>The failure that ought to sink the whole idea looks like this. You add <code class="language-plaintext highlighter-rouge">effectful</code>, a popular effects library. The build runs for a while, then GHC stops on:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ErrorT not in scope
</code></pre></div></div>

<p>The error is in <code class="language-plaintext highlighter-rouge">monad-control</code>, a package you didn’t ask for, pulled in by something else you didn’t ask for. It names a type that left the ecosystem years ago. There’s no version number in it and nothing obvious to grep for.</p>

<p>The cause is a familiar Haskell papercut. <code class="language-plaintext highlighter-rouge">monad-control</code>’s newest git tag, <code class="language-plaintext highlighter-rouge">v1.0.3</code>, declares <code class="language-plaintext highlighter-rouge">build-depends: transformers &lt; 0.6</code>. The toolchain ships <code class="language-plaintext highlighter-rouge">transformers 0.6.1.0</code>, and <code class="language-plaintext highlighter-rouge">ErrorT</code> was removed in <code class="language-plaintext highlighter-rouge">transformers 0.6</code>, so that tagged release can’t compile against the compiler it’s being built with. The bound was relaxed on later commits (HEAD is <code class="language-plaintext highlighter-rouge">1.0.4</code>, with <code class="language-plaintext highlighter-rouge">transformers &lt; 0.7</code>), but no newer git tag carries the fix, so a resolution that follows tags lands on the stale one. cabal’s solver gets around this by finding some other set of versions that works. zinc has no solver to get around with, so it has to notice the problem directly.</p>

<p>This is the part the bet depends on. zinc reads that <code class="language-plaintext highlighter-rouge">&lt; 0.6</code> bound, but it reads it only to explain the failure, never to choose anything. A small detector parses the dependency’s <code class="language-plaintext highlighter-rouge">.cabal</code> and checks each boot-library bound against the version the toolchain actually ships. The code that does this carries a comment reminding the next person that the bound is for diagnostics only and must never feed back into resolution. zinc still pins a commit, not a version range. Let a range start influencing what gets selected and you’ve rebuilt the solver, so it isn’t allowed to.</p>

<p>What you see in place of <code class="language-plaintext highlighter-rouge">ErrorT not in scope</code> is this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ZINC_DEP_BOOT_CONFLICT (exit 3)
  dependency's tag conflicts with a toolchain boot library
  monad-control requires transformers &lt;0.6, but the toolchain ships transformers 0.6.1.0
  hint: its newest release tag is stale; pin it forward to a commit whose
        transformers bound admits 0.6.1.0  ([dependencies.monad-control] rev = "...")
</code></pre></div></div>

<pre class="diagram">
  monad-control, resolved at its newest git tag:

    o--o--o--[ v1.0.3 ]--o--o--&gt;  HEAD (1.0.4)
                |                     |
        transformers &lt;0.6    transformers &lt;0.7
        (excludes 0.6.1.0)   (admits 0.6.1.0)
                |                     |
                +-- forward-pin ----&gt;+
        point the ref past the stale tag, to a
        commit whose bound admits 0.6.1.0
</pre>

<p>Two details deserve more honesty than my first pass gave them.</p>

<p>The forward-pin suggestion is a hint, not a computed answer. Right now zinc clones the dependency’s default branch, checks whether the tip relaxes the bound, and offers that commit if it does. That’s coarse. The tip might be hundreds of commits past the stale tag with a pile of unrelated changes, and it only checks the bound rather than trying a compile. Finding the smallest commit that actually fixes the bound, by bisecting or by walking the <code class="language-plaintext highlighter-rouge">.cabal</code> file’s own history, would be better. It’s filed as future work, not something I’m claiming now. Even the coarse version is the rule working as designed, though: zinc proposes a commit, you decide, your choice gets frozen as explicit state. The tool never resolves anything silently.</p>

<p>And this wasn’t a one-off. Pin <code class="language-plaintext highlighter-rouge">monad-control</code> forward and the build moves exactly one step before hitting the next stale tag, <code class="language-plaintext highlighter-rouge">strict-mutable-base</code>, whose latest git tag (<code class="language-plaintext highlighter-rouge">1.0.0.0</code>) is below what <code class="language-plaintext highlighter-rouge">effectful</code> needs (<code class="language-plaintext highlighter-rouge">&gt;= 1.1.0.0</code>) and is missing a function added later. Same shape, same fix. The second independent case is what convinced me it deserved a real diagnostic instead of a workaround.</p>

<p>So the bet holds, with a caveat I’d rather state than gloss over. Deleting the solver doesn’t make conflicts go away; it means you can’t hide them. A solver would have searched quietly past this one. zinc can’t, so the most it can do is turn the worst error in the build into the most readable one, give you somewhere to start, and let you make the call.</p>

<h2 id="argument-2-the-conflict-thats-left-is-permanent-and-thats-the-right-thing">Argument 2: the conflict that’s left is permanent, and that’s the right thing</h2>

<p>There’s a question lurking under the last story. If a stale version bound is what set the whole mess off, isn’t the no-solver design just borrowing trouble that cabal already handles?</p>

<p>Most of that particular trouble is on loan from the old ecosystem, and it gets paid back as the ecosystem changes. The boot-conflict diagnostic exists only because upstream packages publish cabal version bounds. A zinc-native package declares its dependency as <code class="language-plaintext highlighter-rouge">depends = ["transformers"]</code> with no bound at all and gets whatever the toolchain ships, because boot libraries come with the compiler and are never fetched. In a world where packages described themselves to zinc instead of to cabal, the <code class="language-plaintext highlighter-rouge">transformers &lt; 0.6</code> problem couldn’t occur, because there’d be no bound to go stale. The diagnostic is a bridge to the packages that exist today, and it fires less the more of them describe themselves natively. I’m fine building something that becomes less necessary over time.</p>

<p>One kind of conflict doesn’t go away, though, and it’s worth being clear that zinc has no plan to make it disappear. If package A wants <code class="language-plaintext highlighter-rouge">vector</code> at one ref and package C wants it at another, the one-ref-per-name rule forces a single choice. With no solver, you make that choice. That’s not a gap in the design; it’s the design. The bet is that this situation is rare in practice and that a human picking a ref, once, with the conflict spelled out, beats a solver picking one silently and being wrong in a way you discover three hours into a build.</p>

<pre class="diagram">
              your-app
              /        \
        effectful     some-lib
            |             |
       wants vector    wants vector
          @ ref A        @ ref B
            \             /
             \           /
          one ref per name
          -----------------
          you choose (root override),
          shown in the resolved closure
</pre>

<p>This is also where the most interesting road-not-taken sits. You could imagine computing a forward-pin properly instead of guessing the branch tip: find the minimal commit of a package whose boot-library bounds are all satisfiable against the toolchain. That sounds like Go’s MVS, and it rhymes with it, but it’s a different problem. MVS searches a space where every version across the graph is negotiable. Here the toolchain ships exactly one version of each boot library, a fixed constant, so picking the smallest compatible commit is a deterministic computation rather than a search. No SAT, no negotiation, just an answer.</p>

<p>That would be a genuine selection algorithm, and adopting it would mean letting a bound influence what gets chosen, which is the line Argument 1 spent its whole length defending. So I’ve drawn the fork deliberately rather than pretending it isn’t there. The plan is to expose that computation as a tool you run when you want a better forward-pin suggestion, and never as something that resolves on its own. The bound can compute a hint for you; it still doesn’t get to decide. That keeps the property that matters: every ref in your lockfile is there because a person put it there.</p>

<h2 id="argument-3-driving-ghc-directly-means-inheriting-a-long-tail-and-thats-the-honest-cost">Argument 3: driving GHC directly means inheriting a long tail, and that’s the honest cost</h2>

<p>Dropping the solver is the headline, but the same instinct, do it ourselves rather than delegate, applies to the build itself. zinc drives <code class="language-plaintext highlighter-rouge">ghc --make</code> directly and doesn’t run Cabal’s build machinery. The benefit is a fast, legible build with no <code class="language-plaintext highlighter-rouge">Setup.hs</code> indirection. The cost is real, and <code class="language-plaintext highlighter-rouge">network</code> is where you feel it.</p>

<p><code class="language-plaintext highlighter-rouge">network</code> is the package the entire HTTP and TLS stack sits on, so it had to work, and getting it to compile took four separate fixes that cabal performs for you without comment. The preprocessor needed the package’s own include directories threaded through to find a bundled header. A <code class="language-plaintext highlighter-rouge">build-type: Configure</code> step had to be run from the Hackage sdist, because the git checkout doesn’t ship the generated <code class="language-plaintext highlighter-rouge">./configure</code>. The preprocessor was running over Windows-only modules on Linux and choking, so it had to be scoped to the modules cabal’s conditionals would actually select. And the <code class="language-plaintext highlighter-rouge">MIN_VERSION_*</code> macros that GHC generates for the main compile weren’t reaching the preprocessor’s C step, so a header had to be emitted and included by hand. None of this is clever. It’s the unglamorous tax you take on when you stop delegating to cabal’s builder, and the only honest thing to say about it is that the tax is finite and <code class="language-plaintext highlighter-rouge">network</code> builds clean now. I’m including it because a build tool that only works on toy fixtures isn’t a build tool, and skipping the boring part would be the dishonest way to tell this story.</p>

<figure>
  <img src="/assets/images/hero-four-walls.svg" alt="An aqua line labelled build network punches left to right through four walls labelled include-dirs, configure sdist, preproc scope, and MIN_VERSION, ending at the word linked." />
  <figcaption>Four layers of plumbing cabal hides, each only visible once you clear the one before it.</figcaption>
</figure>

<h2 id="argument-4-the-toolchain-work-can-be-clean-even-when-the-envelope-is-bounded-if-the-boundary-fails-fast">Argument 4: the toolchain work can be clean even when the envelope is bounded, if the boundary fails fast</h2>

<p>The last story is the one with an actual demo at the end. <code class="language-plaintext highlighter-rouge">zinc build --target wasm32-wasi</code> cross-compiles a workspace to WebAssembly, and it does the fiddly parts with no setup from you. It provisions the GHC wasm cross-compiler through the <code class="language-plaintext highlighter-rouge">ghc-wasm-meta</code> flake, produces either a WASI command module or a browser reactor module depending on one setting in your manifest, and for the reactor case it generates the JavaScript FFI glue that lets a browser call into the module.</p>

<p>There’s a runtime trap in here worth knowing about even if you never touch zinc, because it’s a property of GHC’s wasm output rather than anything zinc invented. A reactor module’s runtime isn’t started by the wasm <code class="language-plaintext highlighter-rouge">_initialize</code> call alone; the host has to call <code class="language-plaintext highlighter-rouge">hs_init</code> once before any exported Haskell function runs, or the first call aborts with <code class="language-plaintext highlighter-rouge">newBoundTask: RTS is not initialised</code>. zinc always exports <code class="language-plaintext highlighter-rouge">hs_init</code> from a reactor so the module is loadable, and the proof-point is a real one: a <code class="language-plaintext highlighter-rouge">miso</code> front-end, a virtual-DOM counter, compiled to wasm and clicked in a browser.</p>

<p>Now the part you actually need to plan around. The toolchain plumbing is solved, but the set of packages that can cross-compile is bounded, and zinc tells you so up front instead of failing cryptically at link time. Pure Haskell works. A package with its own C sources works. A package that links an external system library does not, because there’s no prebuilt wasm build of that C library to link against, and you get a typed <code class="language-plaintext highlighter-rouge">ZINC_WASM_UNSUPPORTED</code> with the package named rather than a wall of linker output. So the practical advice is concrete: if you’re building a mostly-Haskell front-end, try it today; if your dependency closure reaches for <code class="language-plaintext highlighter-rouge">zlib</code> or similar, you’ll hit the wall immediately and know exactly why. The boundary moves outward over time, and the gaps that move it, like supporting external system libraries, are tracked as their own work rather than hidden behind an optimistic README.</p>

<figure>
  <img src="/assets/images/hero-wasm-envelope.svg" alt="A rounded boundary labelled wasm32-wasi envelope contains ticked items pure Haskell and in-package C sources. Outside, a zlib token bounces off the boundary with the label ZINC_WASM_UNSUPPORTED, fail-fast. Dashed arrows show the boundary expanding over time." />
  <figcaption>Inside the envelope it builds; an external system library fails fast and named.</figcaption>
</figure>

<h2 id="what-actually-carried-the-work">What actually carried the work</h2>

<p>Two things land together at the end. The first is that <code class="language-plaintext highlighter-rouge">zinc build</code> builds zinc. Self-hosting was the definition of done from the start, because it’s the one test that exercises the resolver, the <code class="language-plaintext highlighter-rouge">.cabal</code> reader, the Nix environment, the build driver and the cache against real packages at once. The second is that everything zinc does is built to be driven by a program as easily as by a person: every command can emit a machine-readable JSON envelope with <code class="language-plaintext highlighter-rouge">--json</code>, every failure carries a stable <code class="language-plaintext highlighter-rouge">ZINC_*</code> code and a category exit code, and there are no interactive prompts to hang on. The tool an agent helped build is a tool an agent can run, and that wasn’t a slogan, it was the constraint that made the agent useful in the first place.</p>

<figure>
  <img src="/assets/images/ouroboros-loop.svg" alt="A circular arrow looping back on itself with the word zinc at its center." />
  <figcaption>zinc builds zinc — the definition of done.</figcaption>
</figure>

<p>That’s the part I’d point at for anyone building real software this way. The leverage wasn’t in generating code quickly. It was in two older disciplines that an agent happens to reward more than a human collaborator does. Writing the design down before the code, so the no-solver rule had somewhere to live and something to be checked against. And designing for inspection, so every decision the tool makes lands as explicit, frozen state you can read back later. The slogan I kept coming back to was explicit state, not explicit effort, and it turned out to describe the working relationship as much as the lockfile.</p>

<p>The bet went further than I expected. Self-hosting landed within a couple of days of the first commit, and wasm cross-compilation and zero-downtime deploys inside the first week. The solver is still gone, and I haven’t yet hit the place where I wish it back.</p>]]></content><author><name>Gareth Stokes</name></author><summary type="html"><![CDATA[Every package manager has a component its users dread. In Haskell it’s the version solver: the part of cabal-install that takes the version ranges scattered across your dependency graph and searches for one assignment that satisfies all of them. When it works you never think about it. When it fails you get a page of text about incompatible bounds on a package three levels down that you’ve never heard of, and your afternoon is gone.]]></summary></entry></feed>