<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en" xml:base="https://blog.gklijs.tech/">
    <title>Gerard Klijs: tech</title>
    <id>https://blog.gklijs.tech/tech/2026</id>
    <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026"/>
    <link rel="self" type="application/atom+xml" href="https://blog.gklijs.tech/tech/2026/feed.xml"/>
    <updated>2026-10-23T14:00:00Z</updated>
    <author><name>Gerard Klijs</name></author>
    <generator uri="https://github.com/gklijs/vados">vados</generator>
    <entry>
        <title>vados 0.3.0: dates, feeds and fewer scripts</title>
        <id>https://blog.gklijs.tech/tech/2026/vados-0-3-0</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/vados-0-3-0"/>
        <published>2026-10-23T14:00:00Z</published>
        <updated>2026-10-23T14:00:00Z</updated>
        <summary>Dated posts, Atom feeds, link previews, inline icons and highlighted code: what vados gained since 0.1.0, and the scripts this blog could delete.</summary>
        <content type="html">&#60;p&#62;Three weeks ago I wrote that vados finally had a command line. Since then there have been two releases, 0.2.0 and now &#60;strong&#62;0.3.0&#60;/strong&#62;, and the second one is the one that made this blog simpler instead of just bigger.&#60;/p&#62;
&#60;p&#62;Before the release I did a dry run: I built the unreleased code and generated this blog with it next to the old version, then compared the output. That turned up a surprise in my own setup. Half of what the blog needed from scripts around vados is now vados&#39;s job, so those scripts are gone.&#60;/p&#62;
&#60;h2&#62;Writing a blog&#60;/h2&#62;
&#60;p&#62;A page can now have a &#60;code&#62;date&#60;/code&#62;, and a section can list its pages by it with &#60;code&#62;childOrder: dateDesc&#60;/code&#62;. A new post shows up first without renumbering anything. Until now I gave the newest post the lowest &#60;code&#62;order&#60;/code&#62; and ran a Python script to rebuild the cards on the year page and the home page. That script is deleted. The home page now just says &#60;code&#62;&#34;latest&#34;: { &#34;from&#34;: &#34;/tech&#34;, &#34;limit&#34;: 4 }&#60;/code&#62;, and a page can show the newest dated pages from anywhere on the site.&#60;/p&#62;
&#60;p&#62;The same dates drive &#60;strong&#62;Atom feeds&#60;/strong&#62;. Set &#60;code&#62;&#34;feed&#34;: true&#60;/code&#62; on a section and vados publishes &#60;code&#62;feed.xml&#60;/code&#62; with every post&#39;s summary and full content. For now a feed lists only the direct children of the page it is on. On this blog the posts sit one level down, under the year, so the feed lives at &#60;a href=&#34;/tech/2026/feed.xml&#34;&#62;/tech/2026/feed.xml&#60;/a&#62;. I&#39;d like it to look deeper, as &#60;code&#62;latest&#60;/code&#62; already does.&#60;/p&#62;
&#60;h2&#62;Being found&#60;/h2&#62;
&#60;ul&#62;
&#60;li&#62;Link previews: &#60;code&#62;og:&#60;/code&#62; and Twitter tags, canonical URLs, and a 1200×630 JPEG crop of the page&#39;s image, because LinkedIn doesn&#39;t reliably show WebP.&#60;/li&#62;
&#60;li&#62;&#60;code&#62;sitemap.xml&#60;/code&#62; and &#60;code&#62;robots.txt&#60;/code&#62;, structured data for search engines, and the &#60;code&#62;rel=&#34;me&#34;&#60;/code&#62; and &#60;code&#62;fediverse:creator&#60;/code&#62; tags Mastodon uses to verify you and credit you under a link.&#60;/li&#62;
&#60;li&#62;Every title now ends with the site&#39;s name, and every page has a proper &#60;code&#62;&#38;lt;h1&#38;gt;&#60;/code&#62;.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;Most of this needs one new line in &#60;code&#62;main.json&#60;/code&#62;, the &#60;code&#62;siteUrl&#60;/code&#62;, which is also why &#60;code&#62;vados check&#60;/code&#62; now warns when it&#39;s missing.&#60;/p&#62;
&#60;h2&#62;Faster pages&#60;/h2&#62;
&#60;p&#62;Icons used to come from the Material Design Icons web font: about 400 KB of render-blocking CSS and font for a handful of icons. In 0.3.0 they&#39;re inlined as SVG. This blog used to run a Node script after every build to cut that font down to the icons I used. That script is gone as well. The hero image is fetched early, every &#60;code&#62;&#38;lt;img&#38;gt;&#60;/code&#62; has its dimensions, and on phones the tiles and social icons no longer fight over the same space.&#60;/p&#62;
&#60;p&#62;Code blocks in Markdown are highlighted at build time now too, with a small stylesheet that is only linked on pages that have code.&#60;/p&#62;
&#60;h2&#62;Between 0.1.0 and here&#60;/h2&#62;
&#60;p&#62;0.2.0 added a site description and a favicon, and the text vados puts on every page (skip link, navigation labels) now follows the site&#39;s language: English, Dutch, German, French or Spanish. It also fixed the breadcrumb announcing &#34;page&#34; instead of the page&#39;s title.&#60;/p&#62;
&#60;pre class=&#34;highlight&#34;&#62;&#60;code class=&#34;language-sh&#34;&#62;&#60;span class=&#34;hl-source hl-shell hl-bash&#34;&#62;&#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-variable hl-function hl-shell&#34;&#62;cargo&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; install vados&#60;/span&#62;
&#60;/span&#62;&#60;/code&#62;&#60;/pre&#62;
&#60;ul&#62;
&#60;li&#62;crates.io: &#60;a href=&#34;https://crates.io/crates/vados&#34;&#62;vados&#60;/a&#62;&#60;/li&#62;
&#60;li&#62;GitHub: &#60;a href=&#34;https://github.com/gklijs/vados&#34;&#62;gklijs/vados&#60;/a&#62;, with the full &#60;a href=&#34;https://github.com/gklijs/vados/blob/main/CHANGELOG.md&#34;&#62;changelog&#60;/a&#62;&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;If you read this blog in a feed reader, you can now subscribe. That was the last thing it was missing.&#60;/p&#62;
&#60;p&#62;&#60;em&#62;This blog is built with vados, too, and now with 0.3.0.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/fort-in-the-grass&#34;&#62;Fort in the grass&#60;/a&#62;, by me. Thick walls, small windows and nothing running inside: roughly what I want from a static site, too.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>Skyhooks, cranes and a library I didn&#39;t write</title>
        <id>https://blog.gklijs.tech/tech/2026/skilj-and-security</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/skilj-and-security"/>
        <published>2026-10-23T00:00:00Z</published>
        <updated>2026-10-23T00:00:00Z</updated>
        <summary>Much of skilj was written by AI, and I don&#39;t know every bolt of it. What it prevents anyway, what it can&#39;t, and why agents only get a keyhole.</summary>
        <content type="html">&#60;p&#62;I don&#39;t know every nut and bolt of &#60;a href=&#34;https://github.com/gklijs/SkilJ&#34;&#62;skilj&#60;/a&#62;. That&#39;s an odd thing
to admit about a library I build and maintain on my own, and a worse one to admit in a post about
security. But it&#39;s true, and the rest of this post doesn&#39;t make much sense without it.&#60;/p&#62;
&#60;p&#62;Much of skilj was written by Claude. I steer, I decide, I review, and I wrote very little of the
code myself. Not because I had a principled plan, but because it was simply too tempting. When the
code shows up faster than I can think about it, &#34;I&#39;ll write it by hand to be sure&#34; stops being a
serious option. So I&#39;m slightly uncomfortable, and I&#39;d rather say so than write the usual paragraph
about taking security very seriously.&#60;/p&#62;
&#60;h2&#62;Skyhooks and cranes&#60;/h2&#62;
&#60;p&#62;Daniel Dennett, in &#60;a href=&#34;https://en.wikipedia.org/wiki/Darwin%27s_Dangerous_Idea&#34;&#62;&#60;em&#62;Darwin&#39;s Dangerous Idea&#60;/em&#62;&#60;/a&#62;,
makes a distinction between skyhooks and cranes. A skyhook lifts things from above, with nothing
holding it up: a mind-first force that makes design something other than mechanism. A crane also
lifts, but it stands on the ground, and you can see how it works.&#60;/p&#62;
&#60;p&#62;In software, the old skyhook was the author. The code is fine because the person who wrote it
understood it. Review was a second person who understood it too. That was never fully true, but it
was true enough that we built our habits on it.&#60;/p&#62;
&#60;p&#62;With AI writing the code, that skyhook is gone. Later, in
&#60;a href=&#34;https://philosophynow.org/issues/129/From_Bacteria_to_Bach_and_Back_by_Daniel_Dennett&#34;&#62;&#60;em&#62;From Bacteria to Bach and Back&#60;/em&#62;&#60;/a&#62;,
Dennett&#39;s phrase for it is &#34;competence without comprehension&#34;. Termites build mounds that ventilate
themselves, and no termite understands ventilation. A model that writes a correct access check is in
a similar position. Searle would call it a &#60;a href=&#34;https://plato.stanford.edu/entries/chinese-room/&#34;&#62;Chinese Room&#60;/a&#62;:
symbols shuffled by rules, with no grasp of what they mean. Dennett argued that the room&#39;s pull comes
from the details it leaves out, like how large the software would have to be. I&#39;d like to think he&#39;s
right about the model. At the end of a long day, I&#39;m less sure about me.&#60;/p&#62;
&#60;p&#62;So it has to be cranes. Things that stand on the ground and can be checked:&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Tests.&#60;/strong&#62; AI-written code needs proper tests, at the very least. The obvious problem is that the
tests are also written by AI. A test that checks the wrong thing passes just as confidently as one
that checks the right thing. Many entries in the design log say that a fix&#39;s test was seen failing
without the fix, and what it did before. Karl Popper would recognise the idea as
&#60;a href=&#34;https://en.wikipedia.org/wiki/Falsifiability&#34;&#62;falsifiability&#60;/a&#62;: a theory that no observation could
contradict isn&#39;t telling you anything, and neither is a test that no bug could make fail. It&#39;s the
closest thing I have to a test for the tests.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Tests that can&#39;t quietly not run.&#60;/strong&#62; Many of the tests need Postgres. A test that skips because the
database didn&#39;t answer looks exactly like a test that passed. CI
&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#ci-silent-skip-guard&#34;&#62;fails the build&#60;/a&#62;
if anything skipped. That guard &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#what-the-tests-cover&#34;&#62;had a hole of its own&#60;/a&#62;,
by the way, which is roughly the point of this post.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/specs/skilj.allium&#34;&#62;A spec&#60;/a&#62; separate from the code&#60;/strong&#62;,
which says what the system guarantees. The design log records an &#60;code&#62;allium check&#60;/code&#62; after changes to it.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;A design log.&#60;/strong&#62; &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md&#34;&#62;&#60;code&#62;docs/architecture.md&#60;/code&#62;&#60;/a&#62;
runs to section 225. In Plato&#39;s &#60;a href=&#34;https://websites.umich.edu/~lsarth/filecabinet/PlatoOnWriting.html&#34;&#62;&#60;em&#62;Phaedrus&#60;/em&#62;&#60;/a&#62;,
Socrates tells of a king who warns that writing gives the appearance of wisdom rather than wisdom
itself. Socrates adds that a text can&#39;t defend itself when you question it. He would have had
opinions about generated code. The design log is my attempt at something that does answer back. Ask
it why something is the way it is, and it tells you: every decision has its reasoning, and each fix
says what was wrong. I have not read all of it in one go, and I&#39;m not sure anyone has. (We only know
about Socrates&#39; story because Plato wrote it down, which I choose to read as permission.)&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;A list of what nothing checks.&#60;/strong&#62; &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/CONTRIBUTING.md#what-the-tests-do-and-dont-verify&#34;&#62;&#60;code&#62;CONTRIBUTING.md&#60;/code&#62;&#60;/a&#62;
sorts the guarantees into three piles: what holds by construction, what tests verify, and what
nothing verifies. That last pile is short, and I&#39;m more proud of it existing than of anything in the
other two.&#60;/p&#62;
&#60;p&#62;None of that removes the real limit, which is me. There is only so much code you can review in a
day, and an AI does not get tired before you do.&#60;/p&#62;
&#60;h2&#62;The blind watchmaker reads your code too&#60;/h2&#62;
&#60;p&#62;Richard Dawkins called natural selection &#60;a href=&#34;https://en.wikipedia.org/wiki/The_Blind_Watchmaker&#34;&#62;&#34;the blind watchmaker&#34;&#60;/a&#62;:
it produces things that look designed, without seeing ahead or understanding what it makes. I find
that a fair description of how a lot of code gets written now. It&#39;s also, less comfortably, a fair
description of how a lot of code gets &#60;em&#62;attacked&#60;/em&#62;. Nobody needs to understand your codebase anymore to
find the place where the authorization check and the query disagree. They can ask a model to look,
and it will read a surprising amount of it without complaint.&#60;/p&#62;
&#60;p&#62;That cuts both ways, and in skilj&#39;s case I&#39;ve mostly been on the friendly side of it. The bugs I
caught myself were mostly about naming and about whether an API made sense to use. Some of the
security bugs came out of review passes, one of them an automated review of code a model had written.
Some examples:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;A caller allowed to &#60;em&#62;submit&#60;/em&#62; commands, but not to &#60;em&#62;read&#60;/em&#62; events, could submit a command it knew
would be rejected. The rejection helpfully listed every event the decision had looked at. Read
access, one deliberate failure at a time. (&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#ultra-review-fixes&#34;&#62;§18&#60;/a&#62;)&#60;/li&#62;
&#60;li&#62;In a bounded context shared by several tenants, a caller with read access could query another
tenant&#39;s projection by key. Closing that properly took
&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#cross-tenant-projection-read-fix-owner-tag&#34;&#62;six sections of the log&#60;/a&#62;,
because once you add owner scoping to one door, you have to find all the other doors.&#60;/li&#62;
&#60;li&#62;A server error used to answer with Postgres&#39; own text, like
&#60;code&#62;relation &#34;bc_acme_corp.events&#34; does not exist&#60;/code&#62;. Each bounded context has its own schema, so that
message tells a stranger your tenant names and your table layout. (&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#database-error-logging&#34;&#62;§93&#60;/a&#62;)&#60;/li&#62;
&#60;li&#62;The one-time secret for creating the first superadmin stayed valid in memory. Revoke every
superadmin, and on any process that hadn&#39;t restarted since, whoever had read the startup log days
earlier could claim the deployment again. (&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#bootstrap-secret-first-claim&#34;&#62;§109&#60;/a&#62;)&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;So I think skilj is more secure than it would have been if I had written it alone in the same time.
But the bar isn&#39;t &#34;better than me alone&#34;. skilj is about storing data, often personal data, and it&#39;s
open source, so anyone with a model and an afternoon can go through it. That bar is a lot higher, and
I can&#39;t honestly say it&#39;s cleared until someone other than me and my tools has tried.&#60;/p&#62;
&#60;h2&#62;What skilj does prevent&#60;/h2&#62;
&#60;p&#62;The guarantee I trust most is the one that holds by construction, whatever the tests do. &#60;strong&#62;No command
sneaks past its consistency check.&#60;/strong&#62; All commands of a bounded context are decided under one Postgres
row lock, and the check, the events and the idempotency key commit or roll back together. Two people
racing for the last seat don&#39;t both get it.
&#60;a href=&#34;/tech/2026/why-skilj-has-no-aggregates&#34;&#62;Why skilj has no aggregates&#60;/a&#62; goes into how that works.&#60;/p&#62;
&#60;p&#62;The rest is ordinary code with tests behind it, which, as established, is a crane and not a skyhook:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;&#60;strong&#62;Callers don&#39;t see what they can&#39;t use.&#60;/strong&#62; Each caller gets a GraphQL schema
&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#caller-scoped-schema&#34;&#62;built from their own grants&#60;/a&#62;,
so another tenant&#39;s types don&#39;t even exist for them. Before that, a query that merely &#60;em&#62;mentioned&#60;/em&#62; a
type would pass or fail validation depending on whether it existed, which is a very polite way of
handing out the tenant list.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;Requests are &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#graphql-body-cap&#34;&#62;bounded&#60;/a&#62;.&#60;/strong&#62;
Body size, query depth and complexity, aliases, page sizes, subscriptions per websocket. One query
that reads everything stopped working a while ago.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;JWTs are checked properly&#60;/strong&#62;: issuer, &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#jwt-audience-required&#34;&#62;audience&#60;/a&#62;,
algorithm, expiry and &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#jwt-nbf-check&#34;&#62;not-before&#60;/a&#62;.
The not-before check was missing, so a token meant for next week worked fine today.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;Then there&#39;s personal data, which is where an event log is at its most awkward. Events are supposed
to be immutable, and people have the &#60;a href=&#34;https://en.wikipedia.org/wiki/Right_to_be_forgotten&#34;&#62;right to be forgotten&#60;/a&#62;.&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;&#60;strong&#62;Sensitive fields are encrypted per person.&#60;/strong&#62; &#60;code&#62;forgetSubject&#60;/code&#62; destroys that person&#39;s key, and their
fields become noise while the rest of history stays intact. This is called
&#60;a href=&#34;https://en.wikipedia.org/wiki/Crypto-shredding&#34;&#62;crypto-shredding&#60;/a&#62;.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#private-fields-third-protection&#34;&#62;Private fields&#60;/a&#62;&#60;/strong&#62;
are stored as is, but redacted on every event and command read for anyone not entitled to them.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#blind-tags&#34;&#62;Blind tags&#60;/a&#62;&#60;/strong&#62; enforce &#34;one
account per email address&#34; without storing the address. The tag is a keyed hash of the normalized
value, with a separate key per constraint, so hashes can&#39;t be joined across bounded contexts. If the
key leaks, it &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#blind-index-key-rotation&#34;&#62;can be rotated&#60;/a&#62;,
and skilj re-wraps every stored hash without needing the original values. It&#39;s the one place in
skilj where being blind is the feature.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#211-secrets-rotatable-credentials-an-adapter-fetches-never-a-normal-read&#34;&#62;Secrets&#60;/a&#62;&#60;/strong&#62;,
like an adapter&#39;s OAuth refresh token, are never returned by a normal query. Only the one adapter
granted the secret can fetch it, and rotating it destroys the old version&#39;s key in the same
transaction.&#60;/li&#62;
&#60;/ul&#62;
&#60;h2&#62;Agents get a keyhole, not a key&#60;/h2&#62;
&#60;p&#62;One part of skilj was designed with AI on the &#60;em&#62;calling&#60;/em&#62; side. I saw the need for an agent that can
operate on specific data, and only that. Giving an agent direct access to a database is risky,
because an agent is not deterministic. The same prompt can do something sensible a hundred times and
something creative the hundred-and-first. Nietzsche wrote in his notebooks that there are no facts,
only &#60;a href=&#34;https://en.wikipedia.org/wiki/Perspectivism&#34;&#62;interpretations&#60;/a&#62;. For a language model that isn&#39;t
a provocation. It&#39;s a description of the job. Whatever text reaches the agent, a ticket, an email, a
web page, gets interpreted, and some of that text was written by someone hoping for a particular
interpretation.&#60;/p&#62;
&#60;p&#62;So the REST API &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#rest-wire-contract&#34;&#62;exists for narrowly scoped callers&#60;/a&#62;.
A token is scoped to exactly one registered type: one command it may submit, or one kind of event it
may read or create. It can be scoped to one owner as well. An agent that handles refund requests can
ask for refunds, for that customer, and do nothing else. It can&#39;t browse, it can&#39;t query around, and
it can&#39;t write SQL, however politely it&#39;s asked to.&#60;/p&#62;
&#60;p&#62;That limits the blast radius. It does not limit intent. If an agent reads a cleverly worded support
ticket and decides a refund is in order, and its token allows refunds, skilj accepts the refund. I
don&#39;t know of a technical fix for &#60;a href=&#34;https://genai.owasp.org/&#34;&#62;prompt injection&#60;/a&#62;, and I&#39;m not going to
pretend skilj has one. What it has is &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#214-generic-command-approval-issue-98&#34;&#62;command approval&#60;/a&#62;:
a command type can require that a submission is a proposal, which someone else approves. The agent may
ask; another account decides. It&#39;s &#60;a href=&#34;https://en.wikipedia.org/wiki/Separation_of_duties&#34;&#62;separation of duties&#60;/a&#62;,
which is old, and which nobody had to invent for AI.&#60;/p&#62;
&#60;h2&#62;What skilj can&#39;t prevent&#60;/h2&#62;
&#60;p&#62;Popper also wrote that our knowledge can only be finite, while our ignorance must necessarily be
infinite. I can&#39;t list the infinite part. This is the finite part I know about.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Your &#60;code&#62;decide()&#60;/code&#62; function.&#60;/strong&#62; skilj makes sure a command is decided against the right events,
atomically. Whether the decision is right is your code. If it lets someone withdraw more than their
balance, skilj will record that mistake with perfect consistency.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;A stolen token.&#60;/strong&#62; skilj checks that a token was issued by an IdP you trust. Whether the person
holding it is the person it was issued to is outside what any library can see. Dennett&#39;s last public
worries were about &#60;a href=&#34;https://www.theatlantic.com/technology/archive/2023/05/problem-counterfeit-people/674075/&#34;&#62;counterfeit people&#60;/a&#62;:
AI convincing enough to pass for a real person. A convincing voice on the phone talking a helpdesk into
resetting an account never reaches skilj as anything but a valid token.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Backups from before someone was forgotten.&#60;/strong&#62; Crypto-shredding destroys a key in the live database.
Last week&#39;s backup still has it, wrapped under the master key. With both, the forgotten person is
remembered again. Blind tags have the same shape: rotating a leaked key protects every copy taken
&#60;em&#62;after&#60;/em&#62; the rotation. With the leaked key and an older copy, an attacker can hash guesses and look
them up, and email addresses are unfortunately very guessable.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Private fields in your projections.&#60;/strong&#62; Redaction happens when events are read. A projection folds
events as they are stored, so if its state keeps a private field, every reader of the projection gets
it. skilj &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#advertised-vs-enforced&#34;&#62;warns at startup&#60;/a&#62;
for every projection that consumes one, because sometimes a staff-only view needs it. A warning is a
nudge, not a wall.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;SQL at compile time.&#60;/strong&#62; Each bounded context has its own Postgres schema, so table names are only
known at runtime, and the compile-time checked queries of &#60;a href=&#34;https://github.com/launchbadge/sqlx&#34;&#62;&#60;code&#62;sqlx&#60;/code&#62;&#60;/a&#62;
can&#39;t be used. That&#39;s over 300 statements built with &#60;code&#62;format!&#60;/code&#62;. What gets formatted in is the schema
name, which is validated and quoted, but a misspelled column compiles fine and fails when it runs. The
Postgres suites are the only check.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;A mixed-version rollout.&#60;/strong&#62; Add a blind tag while an older skilj version is still running, and that
older instance doesn&#39;t know the tag should be hashed. It stores the value in the clear. None of the
startup checks can catch it, because the old version predates them
(&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md#blind-tags&#34;&#62;§205&#60;/a&#62;).&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Dependencies.&#60;/strong&#62; &#60;a href=&#34;https://github.com/EmbarkStudios/cargo-deny&#34;&#62;&#60;code&#62;cargo deny&#60;/code&#62;&#60;/a&#62; fails CI on every &#60;em&#62;known&#60;/em&#62;
&#60;a href=&#34;https://rustsec.org/&#34;&#62;advisory&#60;/a&#62;. The unknown ones are, so far, unknown.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Your database superuser with your master key.&#60;/strong&#62; They can read everything. That&#39;s less a limitation
of skilj than a description of what those two things are.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;And an audit.&#60;/strong&#62; skilj is &#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/README.md#status-and-stability&#34;&#62;pre-1.0&#60;/a&#62;,
has one maintainer, and has had no independent security audit. A lot of review, much of it written
down, but nobody has been paid to break it.&#60;/p&#62;
&#60;h2&#62;Making good mistakes&#60;/h2&#62;
&#60;p&#62;Dennett wrote in &#60;a href=&#34;https://www.themarginalian.org/2013/05/29/intuition-pumps-daniel-dennett-on-making-mistakes/&#34;&#62;&#60;em&#62;Intuition Pumps&#60;/em&#62;&#60;/a&#62;
that the chief trick to making good mistakes is not to hide them, especially not from yourself. The
&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/architecture.md&#34;&#62;design log&#60;/a&#62; is, for a large part, a
list of my mistakes and Claude&#39;s, and I&#39;ve stopped trying to tell which was whose. What matters is that
each one is written down, with its fix, and where the log can show it, a test that failed before the
fix, so the same mistake has to find a new way in.&#60;/p&#62;
&#60;p&#62;If you find one that isn&#39;t written down yet, I&#39;d like to hear about it. If it&#39;s a real hole, please use
&#60;a href=&#34;https://github.com/gklijs/SkilJ/security/advisories/new&#34;&#62;GitHub&#39;s private vulnerability reporting&#60;/a&#62;
rather than an issue. I&#39;ll write it into the log either way, and credit you there if you want the
credit.&#60;/p&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/batman-grappling-gun&#34;&#62;Batman, grappling gun&#60;/a&#62;, by me. He also works alone, and has also
never been audited.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>Why skilj has no aggregates</title>
        <id>https://blog.gklijs.tech/tech/2026/why-skilj-has-no-aggregates</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/why-skilj-has-no-aggregates"/>
        <published>2026-10-22T12:00:00Z</published>
        <updated>2026-10-22T12:00:00Z</updated>
        <summary>Dynamic Consistency Boundaries instead of aggregates: how skilj checks a rule that spans a student and a course in one decision, and what it costs.</summary>
        <content type="html">&#60;p&#62;&#60;a href=&#34;https://github.com/gklijs/SkilJ&#34;&#62;skilj&#60;/a&#62; is a Rust library for building event-sourced
applications on Postgres, and 0.1 was just released. Most event-sourcing libraries are built
around aggregates. skilj has none. This post explains why, using one example that aggregates
handle badly.&#60;/p&#62;
&#60;h2&#62;The example: enrolling a student in a course&#60;/h2&#62;
&#60;p&#62;We have two rules:&#60;/p&#62;
&#60;ol&#62;
&#60;li&#62;A course never has more students than its capacity.&#60;/li&#62;
&#60;li&#62;A student is never enrolled in more than three courses at once.&#60;/li&#62;
&#60;/ol&#62;
&#60;p&#62;The command is &#60;code&#62;EnrollStudentInCourse { student_id, course_id }&#60;/code&#62;.&#60;/p&#62;
&#60;p&#62;With aggregates, you first choose your consistency boundaries. You would naturally get a
&#60;code&#62;Course&#60;/code&#62; aggregate and a &#60;code&#62;Student&#60;/code&#62; aggregate, each with its own event stream, and each able to
guarantee only its own invariants. Rule 1 belongs to &#60;code&#62;Course&#60;/code&#62; and rule 2 belongs to &#60;code&#62;Student&#60;/code&#62;.
A single enrolment has to satisfy both, and no single transaction can see both streams.&#60;/p&#62;
&#60;p&#62;That leaves the usual options, and each has a cost:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;&#60;strong&#62;A saga or process manager.&#60;/strong&#62; Reserve a seat on the course, then check the student, and
release the seat if the student is already at three. That means two writes, a temporary
state that other readers can see, and compensation logic you have to get right.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;A bigger aggregate.&#60;/strong&#62; Make &#60;code&#62;Enrolment&#60;/code&#62; or &#60;code&#62;School&#60;/code&#62; the aggregate. Now every enrolment in
the whole school competes for one stream, and every command replays a history that is
mostly unrelated to it.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;Give up on one rule.&#60;/strong&#62; Check the student limit against a read model and accept that it
will occasionally be wrong.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;None of these is a domain decision. They are workarounds for a modelling choice that had to be
made before you knew which rules you would need.&#60;/p&#62;
&#60;h2&#62;Dynamic Consistency Boundaries&#60;/h2&#62;
&#60;p&#62;Sara Pellegrini and Milan Savić named the alternative
&#60;a href=&#34;https://dcb.events&#34;&#62;Dynamic Consistency Boundaries&#60;/a&#62; (DCB). Events are not appended to a
stream that belongs to an entity. Instead they go into one log per bounded context, and each
event carries &#60;strong&#62;tags&#60;/strong&#62;. A &#60;code&#62;StudentEnrolled&#60;/code&#62; event is tagged with both &#60;code&#62;student: s1&#60;/code&#62; and
&#60;code&#62;course: c7&#60;/code&#62;.&#60;/p&#62;
&#60;p&#62;A command says which tags it needs. Its decision is then made against every earlier event that
carries one of those tags. That is exactly the history the command depends on: no more, and no
less.&#60;/p&#62;
&#60;p&#62;In skilj the enrolment looks like this (trimmed from
&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/skilj-demo/src/courses.rs&#34;&#62;&#60;code&#62;skilj-demo/src/courses.rs&#60;/code&#62;&#60;/a&#62;):&#60;/p&#62;
&#60;pre class=&#34;highlight&#34;&#62;&#60;code class=&#34;language-rust&#34;&#62;&#60;span class=&#34;hl-source hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-impl hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-impl hl-rust&#34;&#62;impl&#60;/span&#62; &#60;/span&#62;&#60;span class=&#34;hl-meta hl-impl hl-rust&#34;&#62;CommandType &#60;span class=&#34;hl-keyword hl-other hl-rust&#34;&#62;for&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-impl hl-rust&#34;&#62; &#60;span class=&#34;hl-entity hl-name hl-impl hl-rust&#34;&#62;EnrollStudentInCourse&#60;/span&#62; &#60;/span&#62;&#60;span class=&#34;hl-meta hl-impl hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-begin hl-rust&#34;&#62;{&#60;/span&#62;
    &#60;span class=&#34;hl-storage hl-type hl-type hl-rust&#34;&#62;type&#60;/span&#62; &#60;span class=&#34;hl-entity hl-name hl-type hl-rust&#34;&#62;Payload&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; EnrollStudentInCoursePayload&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
    &#60;span class=&#34;hl-storage hl-type hl-type hl-rust&#34;&#62;type&#60;/span&#62; &#60;span class=&#34;hl-entity hl-name hl-type hl-rust&#34;&#62;Event&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; CoursesEvent&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
    &#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;const&#60;/span&#62; &#60;span class=&#34;hl-constant hl-other hl-rust&#34;&#62;NAME&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;:&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;&#60;span class=&#34;hl-storage hl-modifier hl-lifetime hl-rust&#34;&#62;&#38;#39;static&#60;/span&#62; &#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;str&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; &#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;EnrollStudentInCourse&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;

    &#60;span class=&#34;hl-comment hl-line hl-double-slash hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-comment hl-rust&#34;&#62;//&#60;/span&#62; Ask for both histories: this student&#38;#39;s and this course&#38;#39;s.
&#60;/span&#62;    &#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-function hl-rust&#34;&#62;fn&#60;/span&#62; &#60;/span&#62;&#60;span class=&#34;hl-entity hl-name hl-function hl-rust&#34;&#62;tag_mappings&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-function hl-parameters hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-parameters hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-function hl-parameters hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-parameters hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62; &#60;span class=&#34;hl-meta hl-function hl-return-type hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;-&#38;gt;&#60;/span&#62; &#60;span class=&#34;hl-meta hl-generic hl-rust&#34;&#62;Vec&#60;span class=&#34;hl-punctuation hl-definition hl-generic hl-begin hl-rust&#34;&#62;&#38;lt;&#60;/span&#62;TagMapping&#60;span class=&#34;hl-punctuation hl-definition hl-generic hl-end hl-rust&#34;&#62;&#38;gt;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62; &#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-begin hl-rust&#34;&#62;{&#60;/span&#62;
        &#60;span class=&#34;hl-support hl-macro hl-rust&#34;&#62;vec!&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;[&#60;/span&#62;
            &#60;span class=&#34;hl-meta hl-path hl-rust&#34;&#62;TagMapping&#60;span class=&#34;hl-punctuation hl-accessor hl-rust&#34;&#62;::&#60;/span&#62;&#60;/span&#62;new&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;student&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62; &#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;student_id&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62;
            &#60;span class=&#34;hl-meta hl-path hl-rust&#34;&#62;TagMapping&#60;span class=&#34;hl-punctuation hl-accessor hl-rust&#34;&#62;::&#60;/span&#62;&#60;/span&#62;new&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;course&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62; &#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;course_id&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62;
        &#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;]&#60;/span&#62;&#60;/span&#62;
    &#60;/span&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-end hl-rust&#34;&#62;}&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;

    &#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-function hl-rust&#34;&#62;fn&#60;/span&#62; &#60;/span&#62;&#60;span class=&#34;hl-entity hl-name hl-function hl-rust&#34;&#62;decide&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-function hl-parameters hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-parameters hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-variable hl-parameter hl-rust&#34;&#62;payload&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;:&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-path hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;Self&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-accessor hl-rust&#34;&#62;::&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;Payload, &#60;span class=&#34;hl-variable hl-parameter hl-rust&#34;&#62;matching_events&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;:&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;[&#60;span class=&#34;hl-meta hl-path hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;&#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;Self&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-accessor hl-rust&#34;&#62;::&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;Event]&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-function hl-parameters hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-parameters hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62; &#60;span class=&#34;hl-meta hl-function hl-return-type hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;-&#38;gt;&#60;/span&#62; CommandDecision&#60;/span&#62; &#60;/span&#62;&#60;span class=&#34;hl-meta hl-function hl-rust&#34;&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-begin hl-rust&#34;&#62;{&#60;/span&#62;
        &#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;let&#60;/span&#62; &#60;span class=&#34;hl-support hl-type hl-rust&#34;&#62;Some&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;capacity&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;course_capacity&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;matching_events&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;payload.course_id&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-control hl-rust&#34;&#62;else&#60;/span&#62; &#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-begin hl-rust&#34;&#62;{&#60;/span&#62;
            &#60;span class=&#34;hl-keyword hl-control hl-rust&#34;&#62;return&#60;/span&#62; &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;reject&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;course_not_found&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
        &#60;/span&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-end hl-rust&#34;&#62;}&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
        &#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;let&#60;/span&#62; roster &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;active_students_in_course&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;matching_events&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;payload.course_id&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
        &#60;span class=&#34;hl-keyword hl-control hl-rust&#34;&#62;if&#60;/span&#62; roster.&#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;len&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;as&#60;/span&#62; &#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;i64&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;gt;&#60;/span&#62;&#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; capacity &#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-begin hl-rust&#34;&#62;{&#60;/span&#62;
            &#60;span class=&#34;hl-keyword hl-control hl-rust&#34;&#62;return&#60;/span&#62; &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;reject&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;course_full&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
        &#60;/span&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-end hl-rust&#34;&#62;}&#60;/span&#62;&#60;/span&#62;
        &#60;span class=&#34;hl-storage hl-type hl-rust&#34;&#62;let&#60;/span&#62; schedule &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;active_courses_for_student&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;matching_events&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;payload.student_id&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
        &#60;span class=&#34;hl-keyword hl-control hl-rust&#34;&#62;if&#60;/span&#62; schedule.&#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;len&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;gt;&#60;/span&#62;&#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;=&#60;/span&#62; &#60;span class=&#34;hl-constant hl-other hl-rust&#34;&#62;MAX_ACTIVE_COURSES_PER_STUDENT&#60;/span&#62; &#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-begin hl-rust&#34;&#62;{&#60;/span&#62;
            &#60;span class=&#34;hl-keyword hl-control hl-rust&#34;&#62;return&#60;/span&#62; &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;reject&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;student_course_limit_reached&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-terminator hl-rust&#34;&#62;;&#60;/span&#62;
        &#60;/span&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-end hl-rust&#34;&#62;}&#60;/span&#62;&#60;/span&#62;
        &#60;span class=&#34;hl-support hl-function hl-rust&#34;&#62;accept&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-begin hl-rust&#34;&#62;(&#60;/span&#62;&#60;span class=&#34;hl-string hl-quoted hl-double hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-begin hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;StudentEnrolled&#60;span class=&#34;hl-punctuation hl-definition hl-string hl-end hl-rust&#34;&#62;&#38;quot;&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-punctuation hl-separator hl-rust&#34;&#62;,&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-rust&#34;&#62;&#38;amp;&#60;/span&#62;payload&#60;/span&#62;&#60;span class=&#34;hl-meta hl-group hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-group hl-end hl-rust&#34;&#62;)&#60;/span&#62;&#60;/span&#62;
    &#60;/span&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-end hl-rust&#34;&#62;}&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;
&#60;/span&#62;&#60;span class=&#34;hl-meta hl-block hl-rust&#34;&#62;&#60;span class=&#34;hl-punctuation hl-section hl-block hl-end hl-rust&#34;&#62;}&#60;/span&#62;&#60;/span&#62;&#60;/span&#62;
&#60;/span&#62;&#60;/code&#62;&#60;/pre&#62;
&#60;p&#62;&#60;code&#62;decide&#60;/code&#62; is a pure function. It gets the union of one student&#39;s history and one course&#39;s
history, checks both rules, and returns either the events to append or a rejection. There is
no saga, no reservation and no compensation. The command that needs both facts simply asks for
both.&#60;/p&#62;
&#60;p&#62;Other commands ask for less. &#60;code&#62;OpenCourse&#60;/code&#62; only needs the &#60;code&#62;course&#60;/code&#62; tag. An enrolment of a
different student in a different course shares no events with this one and never reads them.
The boundary is defined per command, which is what &#34;dynamic&#34; means here.&#60;/p&#62;
&#60;p&#62;Uniqueness rules, such as &#34;an email address can be registered once&#34;, follow the same pattern.
The command asks for the &#60;code&#62;email&#60;/code&#62; tag and rejects if any event already has it. You don&#39;t need a
unique index or a reservation table. (skilj can also make that tag a keyed hash, so the address
is never stored in the clear, but that deserves its own post.)&#60;/p&#62;
&#60;h2&#62;How skilj keeps it atomic&#60;/h2&#62;
&#60;p&#62;A pure &#60;code&#62;decide&#60;/code&#62; function is only half of the story. Two enrolments racing for the last seat
must not both be accepted.&#60;/p&#62;
&#60;p&#62;The DCB specification describes an optimistic approach: you append with a condition that says
&#34;fail if any event matching my query was added after position N&#34;, and retry if it fails. skilj
chooses something simpler. All commands for one bounded context are serialized through a
single Postgres row lock. While a command holds the lock, skilj reads its matching events,
runs &#60;code&#62;decide&#60;/code&#62;, appends the result with a gapless sequence number, and commits. Because nothing
can interleave, there is no conflict to detect and nothing to retry. skilj&#39;s test suite races
two enrolments for the last seat to check this.&#60;/p&#62;
&#60;p&#62;Serializing commands sounds slow, and there is a ceiling, so here it is. Concurrent commands
are group-committed: callers queue up, one of them takes the lock and decides the whole batch,
and commits once. Under load the batches grow, so the lock cost is shared. On a development
machine, one bounded context handles roughly 400 to 850 commands per second when commands touch
different entities, and around 100 per second when every command hits the same entity with a
growing history. Snapshots help with the second case. The full numbers and method are in
&#60;a href=&#34;https://github.com/gklijs/SkilJ/blob/main/docs/performance.md&#34;&#62;&#60;code&#62;docs/performance.md&#60;/code&#62;&#60;/a&#62;.&#60;/p&#62;
&#60;p&#62;Different bounded contexts never share a lock, so the real lever is to split unrelated domains
or tenants into separate contexts. If one context needs thousands of commands per second,
skilj is not the right tool, and the README says so.&#60;/p&#62;
&#60;h2&#62;What you get on top&#60;/h2&#62;
&#60;p&#62;The tags make the rest of the library easier to build:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;&#60;strong&#62;GraphQL and REST APIs&#60;/strong&#62; generated from the registered event and command types. Each
caller&#39;s GraphQL schema only contains what their grants allow, and it can act as an Apollo
Federation subgraph.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;Projections and snapshots&#60;/strong&#62; for reads, so queries don&#39;t have to replay events.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;Personal data handling.&#60;/strong&#62; Sensitive fields are encrypted per subject, and &#60;code&#62;forgetSubject&#60;/code&#62;
makes them unreadable without rewriting the log.&#60;/li&#62;
&#60;li&#62;&#60;strong&#62;The things every event-sourced system ends up building:&#60;/strong&#62; deadlines, idempotency keys,
upcasting, command approval, parked deliveries with redrive, and bridges to Kafka,
AMQP 1.0, NATS JetStream and Temporal.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;All of it runs on one Postgres database. There is no separate event store to operate.&#60;/p&#62;
&#60;h2&#62;Try it&#60;/h2&#62;
&#60;p&#62;You only need Rust. The template starts its own embedded Postgres:&#60;/p&#62;
&#60;pre class=&#34;highlight&#34;&#62;&#60;code class=&#34;language-sh&#34;&#62;&#60;span class=&#34;hl-source hl-shell hl-bash&#34;&#62;&#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-variable hl-function hl-shell&#34;&#62;cargo&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; install cargo-generate&#60;/span&#62;
&#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-variable hl-function hl-shell&#34;&#62;cargo&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; generate&#60;span class=&#34;hl-variable hl-parameter hl-option hl-shell&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-parameter hl-shell&#34;&#62; --&#60;/span&#62;git&#60;/span&#62; https://github.com/gklijs/SkilJ.git templates/skilj-template&#60;span class=&#34;hl-variable hl-parameter hl-option hl-shell&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-parameter hl-shell&#34;&#62; --&#60;/span&#62;name&#60;/span&#62; my-wallet&#60;/span&#62;
&#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-support hl-function hl-cd hl-shell&#34;&#62;cd&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; my-wallet&#60;/span&#62; &#60;span class=&#34;hl-keyword hl-operator hl-logical hl-and hl-shell&#34;&#62;&#38;amp;&#38;amp;&#60;/span&#62; &#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-variable hl-function hl-shell&#34;&#62;cargo&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; run&#60;span class=&#34;hl-variable hl-parameter hl-option hl-shell&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-parameter hl-shell&#34;&#62; --&#60;/span&#62;bin&#60;/span&#62; server&#60;/span&#62;
&#60;/span&#62;&#60;/code&#62;&#60;/pre&#62;
&#60;p&#62;It prints commands you can paste to make a deposit, see an overdraft rejected, and query the
balance.&#60;/p&#62;
&#60;p&#62;skilj is pre-1.0 and has one maintainer. Releases follow semver the way Cargo does, and schema
upgrades are automatic and forward-only. I&#39;d like to hear where the API feels wrong, which
features are missing, and whether DCB works for your domain. Issues and discussions are open at
&#60;a href=&#34;https://github.com/gklijs/SkilJ&#34;&#62;github.com/gklijs/SkilJ&#60;/a&#62;.&#60;/p&#62;
&#60;p&#62;&#60;em&#62;A note on the name: skilj is an anagram of my last name, Klijs. In Swedish, &#34;skilja&#34; means to
separate, which turned out to describe the design: each command is decided apart from
everything it doesn&#39;t touch.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/sun-on-the-water&#34;&#62;Sun on the water&#60;/a&#62;, by me.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>schema_registry_converter 6.0</title>
        <id>https://blog.gklijs.tech/tech/2026/schema-registry-converter-6</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/schema-registry-converter-6"/>
        <published>2026-10-22T09:00:00Z</published>
        <updated>2026-10-22T09:00:00Z</updated>
        <summary>Eight years after 0.1.0, the Rust crate for Kafka&#39;s Schema Registry gets its biggest update: runtime Protobuf encoding, schema ids in headers, and 7.5 million downloads.</summary>
        <content type="html">&#60;p&#62;&#60;em&#62;Originally posted on LinkedIn on 22 October 2026.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;Eight years ago I published version 0.1.0 of a small Rust crate. It solved one problem: reading Kafka messages that a Java service had encoded with Confluent Schema Registry. Today I released 6.0.0, the biggest update since that first version.&#60;/p&#62;
&#60;p&#62;Some numbers I didn&#39;t expect when I started:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;7.5 million downloads on crates.io, 1.4 million of them in the last 90 days&#60;/li&#62;
&#60;li&#62;546 GitHub repositories depend on it, plus 10 published crates&#60;/li&#62;
&#60;li&#62;27 contributors and 28 releases since 2018&#60;/li&#62;
&#60;/ul&#62;
&#60;h2&#62;Why use a schema registry?&#60;/h2&#62;
&#60;p&#62;Kafka itself doesn&#39;t care what you put in a message; to Kafka it&#39;s just bytes. That&#39;s great for throughput, but it means the producer and every consumer have to agree on the format, usually without talking to each other. A schema registry turns that agreement into something you can check:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;Each message carries a small schema id, so the full schema doesn&#39;t travel with every record.&#60;/li&#62;
&#60;li&#62;Compatibility rules reject a breaking change before it reaches production, not after a consumer starts failing.&#60;/li&#62;
&#60;li&#62;Avro, Protobuf and JSON Schema all work, so teams can keep the format they already use.&#60;/li&#62;
&#60;li&#62;A Rust service and a Java service can read each other&#39;s data with no glue code.&#60;/li&#62;
&#60;/ul&#62;
&#60;h2&#62;What&#39;s new in 6.0&#60;/h2&#62;
&#60;ul&#62;
&#60;li&#62;A new &#60;code&#62;ProtoEncoder&#60;/code&#62; for Protobuf messages that are only known at runtime. It can also encode straight from JSON.&#60;/li&#62;
&#60;li&#62;Protobuf decoding is rebuilt on protox and prost-reflect.&#60;/li&#62;
&#60;li&#62;JSON Schema validation now uses the jsonschema crate, with compiled validators cached.&#60;/li&#62;
&#60;li&#62;The schema id can be read from and written to Kafka headers everywhere, not only in the payload.&#60;/li&#62;
&#60;li&#62;A declared minimum Rust version (1.88), and the crate is on the 2024 edition.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;It&#39;s a major release, so there are breaking changes. The release notes have a short &#34;Upgrading from 5.x&#34; section.&#60;/p&#62;
&#60;p&#62;Thanks to everyone who filed an issue, sent a PR or just used it in production. If you run Kafka with Rust, I&#39;d love to hear what you&#39;re building.&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;GitHub: &#60;a href=&#34;https://github.com/gklijs/schema_registry_converter&#34;&#62;gklijs/schema_registry_converter&#60;/a&#62;&#60;/li&#62;
&#60;li&#62;crates.io: &#60;a href=&#34;https://crates.io/crates/schema_registry_converter&#34;&#62;schema_registry_converter&#60;/a&#62;&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/train-in-a-hurry&#34;&#62;Train in a hurry&#60;/a&#62;, by me.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>skilj 0.1.0: separating things, on purpose</title>
        <id>https://blog.gklijs.tech/tech/2026/skilj-0-1-0</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/skilj-0-1-0"/>
        <published>2026-10-19T00:00:00Z</published>
        <updated>2026-10-19T00:00:00Z</updated>
        <summary>The first skilj release with a compatibility promise, what it does, why the name fits, and an honest word on where it stands.</summary>
        <content type="html">&#60;p&#62;&#60;em&#62;Originally published as a LinkedIn article on 19 October 2026. The code has since moved from Codeberg to GitHub; the links are updated.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;I tagged skilj 0.1.0 today. After a long run of 0.0.x releases that came with no promises at all, this is the first version where I&#39;m willing to say: upgrades within 0.1 won&#39;t break your code.&#60;/p&#62;
&#60;p&#62;So, what is it? skilj is a Rust library for building event-sourced applications on top of Postgres. You describe your domain as events (things that happened) and commands (requests to make something happen), and skilj takes care of storage, consistency, and serving it all over GraphQL and REST.&#60;/p&#62;
&#60;p&#62;First, the name, because people ask. It&#39;s an anagram of my last name, Klijs. I only found out later that skilja is Swedish for &#34;to separate&#34;. I&#39;m taking that as a sign, because separating things is pretty much the whole design.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Commands are kept apart from the history they don&#39;t care about.&#60;/strong&#62; Most event sourcing libraries make you pick an aggregate up front, an Account or an Order, and every command replays that aggregate&#39;s full history. skilj uses a Dynamic Consistency Boundary instead: events carry tags, and a command is checked against exactly the events that share its tags. Enrolling a student in a course can check both the student and the course in one atomic decision, without either of them having to &#34;own&#34; the other. No more arguing in a meeting room about which aggregate a feature belongs to. (You can still argue about naming. I can&#39;t fix everything.)&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;Personal data is kept apart from the event log.&#60;/strong&#62; Sensitive fields are encrypted per person, so when someone asks to be forgotten, you throw away their key and their data turns into noise, while the rest of history stays intact. There are also &#34;blind&#34; tags, so you can enforce things like &#34;one account per email address&#34; without ever storing the email address in plain text.&#60;/p&#62;
&#60;p&#62;&#60;strong&#62;And reads are kept apart from writes&#60;/strong&#62;, with projections, snapshots and the rest.&#60;/p&#62;
&#60;p&#62;On top of that it generates the APIs from your types. The GraphQL schema is tailored to whoever is calling, it can act as an Apollo Federation subgraph, and there are live subscriptions. And there&#39;s a list of things you usually end up building yourself: deadlines and recurring schedules, idempotency keys, command approval with a second pair of eyes, upcasting old events, parking failed deliveries so you can retry them, and bridges to Kafka, AMQP, NATS JetStream and Temporal. All of it lives in one library on one Postgres database. No extra event store to run, patch and get paged about at 3am.&#60;/p&#62;
&#60;p&#62;Some numbers from the road to 0.1:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;15 crates published to crates.io&#60;/li&#62;
&#60;li&#62;a design log in the repo that is now at section 221, with the reasoning behind each decision, including the ones I got wrong first&#60;/li&#62;
&#60;li&#62;one target directory that grew to 187 GB this week and filled my disk in the middle of release prep. Rust compile times are a lifestyle.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;To be honest about where it stands: it&#39;s pre-1.0, it&#39;s built and maintained by one person, and it hasn&#39;t had an independent security audit. What 0.1 does promise is semantic versioning, automatic database upgrades (tested all the way back to the very first release&#39;s schema), and a minimum Rust version that&#39;s checked in CI.&#60;/p&#62;
&#60;p&#62;If you want to kick the tires, this gives you a running project with a small wallet example:&#60;/p&#62;
&#60;pre class=&#34;highlight&#34;&#62;&#60;code class=&#34;language-sh&#34;&#62;&#60;span class=&#34;hl-source hl-shell hl-bash&#34;&#62;&#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-variable hl-function hl-shell&#34;&#62;cargo&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; generate&#60;span class=&#34;hl-variable hl-parameter hl-option hl-shell&#34;&#62;&#60;span class=&#34;hl-punctuation hl-definition hl-parameter hl-shell&#34;&#62; --&#60;/span&#62;git&#60;/span&#62; https://github.com/gklijs/SkilJ.git templates/skilj-template&#60;/span&#62;
&#60;/span&#62;&#60;/code&#62;&#60;/pre&#62;
&#60;p&#62;Feedback, issues, and &#34;why on earth did you do it like this&#34; questions are all very welcome.&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;GitHub: &#60;a href=&#34;https://github.com/gklijs/SkilJ&#34;&#62;gklijs/SkilJ&#60;/a&#62;&#60;/li&#62;
&#60;li&#62;crates.io: &#60;a href=&#34;https://crates.io/crates/skilj&#34;&#62;skilj&#60;/a&#62;&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/path-through-the-trees&#34;&#62;Path through the trees&#60;/a&#62;, by me.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>Tiered storage for a DCB event store?</title>
        <id>https://blog.gklijs.tech/tech/2026/tiered-storage-for-dcb</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/tiered-storage-for-dcb"/>
        <published>2026-10-14T00:00:00Z</published>
        <updated>2026-10-14T00:00:00Z</updated>
        <summary>Measurements on moving old event payloads to S3, why DCB keeps the tag index hot forever, and an open question on when an event can go cold.</summary>
        <content type="html">&#60;p&#62;&#60;em&#62;Originally posted as a question to the DCB community on 14 October 2026. The issue it links has since moved from Codeberg to GitHub, with the same number.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;I&#39;m working on skilj, a Rust event store on Postgres built around DCB. I&#39;ve been exploring moving old event payloads to object storage such as S3, because an event store only grows. In DCB the hard part is clear: a consistency query has to see every matching event forever. A uniqueness claim from five years ago still decides today&#39;s command. So the tag index has to stay hot, and only payloads can move.&#60;/p&#62;
&#60;p&#62;I measured it with 200k events, two tags each, on Postgres:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;~100 B payloads: only 16% of the storage could move, so it&#39;s not worth it&#60;/li&#62;
&#60;li&#62;~1 KB: 57%&#60;/li&#62;
&#60;li&#62;~4 KB: 65%&#60;/li&#62;
&#60;li&#62;about 500–800 bytes per event stay hot whatever the payload size (row, indexes, tag index)&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;The open question is &#60;strong&#62;when an event can safely go cold&#60;/strong&#62;. Age or event count is easy, but it hurts long-lived tags: a command deciding on an old account would fetch its whole history from S3. My current thinking is that an event is cold once every query that can match it is covered, by a snapshot past it or by a &#34;latest only&#34; query item that a newer event answers.&#60;/p&#62;
&#60;p&#62;I&#39;m curious:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;Has anyone hit storage limits with a DCB store, or is it more about backups and vacuum?&#60;/li&#62;
&#60;li&#62;How do other DCB implementations handle very long-lived tags: snapshots, or something else?&#60;/li&#62;
&#60;li&#62;Any other approach I&#39;m missing?&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;The write-up with the numbers, a design sketch and what KurrentDB, Kafka and Axon do is in &#60;a href=&#34;https://github.com/gklijs/SkilJ/issues/92&#34;&#62;skilj issue #92&#60;/a&#62;. Nothing is built yet, so all ideas are welcome, including &#34;don&#39;t&#34;.&#60;/p&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/floor-after-floor&#34;&#62;Floor after floor&#60;/a&#62;, by me.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>skilj 0.0.10 speaks Federation</title>
        <id>https://blog.gklijs.tech/tech/2026/skilj-0-0-10-federation</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/skilj-0-0-10-federation"/>
        <published>2026-10-11T00:00:00Z</published>
        <updated>2026-10-11T00:00:00Z</updated>
        <summary>skilj joins a supergraph as an Apollo Federation v2 subgraph, with consistency queries, 25-50x faster catch-up and database epochs.</summary>
        <content type="html">&#60;p&#62;&#60;em&#62;Originally posted on LinkedIn on 11 October 2026. The code has since moved from Codeberg to GitHub; the link is updated.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;Apollo held their summit last week and everyone came home talking supergraphs. Our timing was good: in this release skilj&#39;s GraphQL endpoint can join a supergraph as an Apollo Federation v2 subgraph.&#60;/p&#62;
&#60;p&#62;Quick refresher: skilj is an event-sourcing framework in Rust on Postgres. You define commands and events, and you get GraphQL and REST APIs, projections, snapshots and messaging bridges.&#60;/p&#62;
&#60;h2&#62;Federation v2&#60;/h2&#62;
&#60;ul&#62;
&#60;li&#62;Plugs into Apollo Router (2 and the 3.0 preview), Hive Router or Gateway, Cosmo, or schema stitching.&#60;/li&#62;
&#60;li&#62;Projections become entities, and each lookup is checked against the caller&#39;s own permissions.&#60;/li&#62;
&#60;li&#62;CI composes the schema with both Apollo&#39;s and Hive&#39;s composition on every build.&#60;/li&#62;
&#60;/ul&#62;
&#60;h2&#62;Commands read only what they need (Dynamic Consistency Boundaries)&#60;/h2&#62;
&#60;ul&#62;
&#60;li&#62;Consistency queries: a command names exactly the events its decision depends on.&#60;/li&#62;
&#60;li&#62;&#60;code&#62;nothing()&#60;/code&#62;: for commands that need no history. On one hot account that took us from 110–160 to 2,500–5,000 commands/s.&#60;/li&#62;
&#60;li&#62;Snapshots as query items: a transfer between two accounts can decide on a snapshot of each.&#60;/li&#62;
&#60;li&#62;New warnings for slow reads and slow decides, with defaults taken from our benchmarks, so you find out before your users do.&#60;/li&#62;
&#60;/ul&#62;
&#60;h2&#62;Speed&#60;/h2&#62;
&#60;ul&#62;
&#60;li&#62;Projection catch-up is 25–50× faster.&#60;/li&#62;
&#60;li&#62;Fewer round trips and no connection ping per command. With 1 ms to Postgres, a single caller went from 46 to about 26 ms per command.&#60;/li&#62;
&#60;li&#62;Batches are written as sets, and sync projections fold a whole batch at once.&#60;/li&#62;
&#60;li&#62;A small Kafka producer setting (&#60;code&#62;linger.ms=0&#60;/code&#62;) takes outbound from about 165 to about 2,000 events/s.&#60;/li&#62;
&#60;/ul&#62;
&#60;h2&#62;Safety&#60;/h2&#62;
&#60;ul&#62;
&#60;li&#62;Database epochs: after a failover or restore, a stale position is refused instead of quietly skipping events.&#60;/li&#62;
&#60;li&#62;Per-message idempotency keys, so NATS and AMQP redeliveries are no longer lost.&#60;/li&#62;
&#60;li&#62;Command dry-runs: see what a command would decide without persisting anything.&#60;/li&#62;
&#60;li&#62;A conformance suite that checks GraphQL and REST give the same answers.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;Plus a pile of fixes, new benchmarks and deployment guidance in &#60;code&#62;docs/performance.md&#60;/code&#62;.&#60;/p&#62;
&#60;p&#62;We&#39;re still at 0.0.x, so some things broke: &#60;code&#62;nextCursor&#60;/code&#62; on &#60;code&#62;GET /v1/events&#60;/code&#62; is now an opaque string, &#60;code&#62;submit_command&#60;/code&#62; takes snapshot states, and a few signatures changed in &#60;code&#62;skilj-graphql&#60;/code&#62; and &#60;code&#62;skilj-bridge&#60;/code&#62;. Read the CHANGELOG before you upgrade.&#60;/p&#62;
&#60;p&#62;All 15 crates are on crates.io, and the code is at &#60;a href=&#34;https://github.com/gklijs/SkilJ&#34;&#62;gklijs/SkilJ&#60;/a&#62;.&#60;/p&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/where-the-roads-meet-the-sea&#34;&#62;Where the roads meet the sea&#60;/a&#62;, by me.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
    <entry>
        <title>vados 0.1.0: now you can run it</title>
        <id>https://blog.gklijs.tech/tech/2026/vados-0-1-0</id>
        <link rel="alternate" type="text/html" href="https://blog.gklijs.tech/tech/2026/vados-0-1-0"/>
        <published>2026-10-07T00:00:00Z</published>
        <updated>2026-10-07T00:00:00Z</updated>
        <summary>Four years after shipping a static site generator without a binary, vados gets a command line: init, check and authoring commands.</summary>
        <content type="html">&#60;p&#62;&#60;em&#62;Originally posted on LinkedIn on 7 October 2026.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;Four years ago I published vados 0.0.3 to crates.io. It was a static site generator you couldn&#39;t actually run, because I&#39;d only shipped the library. No binary. Just vibes.&#60;/p&#62;
&#60;p&#62;Today I&#39;m fixing that with &#60;strong&#62;vados 0.1.0&#60;/strong&#62;.&#60;/p&#62;
&#60;p&#62;Some background: the project&#39;s built around Bulma (the CSS framework), and Bulma is also a Dragon Ball character. So I went looking for the fastest Dragon Ball character and found Vados, who can destroy and recreate entire planets. Turns out that&#39;s basically what a static site generator does to your output folder every build. Naming things is hard, so I let anime do it.&#60;/p&#62;
&#60;p&#62;What&#39;s new:&#60;/p&#62;
&#60;ul&#62;
&#60;li&#62;&#60;code&#62;vados init&#60;/code&#62; sets up a new site, ready to deploy on Netlify.&#60;/li&#62;
&#60;li&#62;&#60;code&#62;vados check&#60;/code&#62; tells you everything that&#39;s wrong with your site before your visitors do.&#60;/li&#62;
&#60;li&#62;Commands to add pages, images, menu items and social links without hand-editing JSON. My favourite kind of JSON is the kind I don&#39;t type.&#60;/li&#62;
&#60;li&#62;Accessibility built in: skip links, landmarks, labels on every icon button, and alt text is required, not optional.&#60;/li&#62;
&#60;li&#62;Luma integration, so you can embed an event or a register button on a page. I built this before I even had a Luma account, which is definitely the right order to do things in.&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;It&#39;s still very much a scratch-my-own-itch project, so I mostly only add what I&#39;d use myself. But if you like Markdown, Bulma and sites that just work without a big framework behind them, give it a try:&#60;/p&#62;
&#60;pre class=&#34;highlight&#34;&#62;&#60;code class=&#34;language-sh&#34;&#62;&#60;span class=&#34;hl-source hl-shell hl-bash&#34;&#62;&#60;span class=&#34;hl-meta hl-function-call hl-shell&#34;&#62;&#60;span class=&#34;hl-variable hl-function hl-shell&#34;&#62;cargo&#60;/span&#62;&#60;/span&#62;&#60;span class=&#34;hl-meta hl-function-call hl-arguments hl-shell&#34;&#62; install vados&#60;/span&#62;
&#60;/span&#62;&#60;/code&#62;&#60;/pre&#62;
&#60;ul&#62;
&#60;li&#62;crates.io: &#60;a href=&#34;https://crates.io/crates/vados&#34;&#62;vados&#60;/a&#62;&#60;/li&#62;
&#60;li&#62;GitHub: &#60;a href=&#34;https://github.com/gklijs/vados&#34;&#62;gklijs/vados&#60;/a&#62;&#60;/li&#62;
&#60;/ul&#62;
&#60;p&#62;Next up: actually registering at Luma.&#60;/p&#62;
&#60;p&#62;&#60;em&#62;This blog is built with vados, too.&#60;/em&#62;&#60;/p&#62;
&#60;p&#62;&#60;em&#62;Photo: &#60;a href=&#34;/photos/2026/batman-up-close&#34;&#62;Batman, up close&#60;/a&#62;, by me.&#60;/em&#62;&#60;/p&#62;
</content>
    </entry>
</feed>