<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Joshua Fields</title>
    <description>The latest articles on DEV Community by Joshua Fields (@joshua-fields-dev).</description>
    <link>https://dev.to/joshua-fields-dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3998561%2Fe2e818a6-d96e-4769-8db5-b25f1c98ac68.jpg</url>
      <title>DEV Community: Joshua Fields</title>
      <link>https://dev.to/joshua-fields-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joshua-fields-dev"/>
    <language>en</language>
    <item>
      <title>Keeping Revisions in Commit Order on Postgres</title>
      <dc:creator>Joshua Fields</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:48:40 +0000</pubDate>
      <link>https://dev.to/joshua-fields-dev/keeping-revisions-in-commit-order-on-postgres-2a7i</link>
      <guid>https://dev.to/joshua-fields-dev/keeping-revisions-in-commit-order-on-postgres-2a7i</guid>
      <description>&lt;p&gt;Anne creates a document. The app inserts the row, tells the authorization service she owns it, and then the payment fails. The document is rolled back. Her permission, sitting in a separate service, is not.&lt;/p&gt;

&lt;p&gt;I've written too many transactional outboxes to patch exactly this. &lt;a href="https://github.com/Joshua-Fields/pemba" rel="noopener noreferrer"&gt;pemba&lt;/a&gt; keeps permissions in your own database, so the relationship write commits or rolls back with the document. On SQLite that was most of the work. On Postgres it raised the real problem: numbering the writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guarantee
&lt;/h2&gt;

&lt;p&gt;Every write in pemba commits at a revision, a &lt;code&gt;u64&lt;/code&gt; from one counter per store. Reads resolve to exactly one revision, which is what makes &lt;code&gt;at_least=rev&lt;/code&gt; (read-your-writes) and &lt;code&gt;at_exactly&lt;/code&gt; (stable paging) work. Both depend on one rule: once any reader has observed revision N, no write numbered N or lower may become visible afterwards.&lt;/p&gt;

&lt;p&gt;SQLite gives you this for free, because one writer holds the lock from drawing a number until it commits. Postgres runs writers concurrently, and they commit in any order. Draw the number with &lt;code&gt;nextval()&lt;/code&gt; at write time and this happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T1  draws revision 10
T2  draws revision 11
T2  commits                  a reader sees head 11
T1  commits                  revision 10 appears below a head someone already saw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the spike below, that naive scheme broke the rule in 39,355 of 39,371 observations.&lt;/p&gt;

&lt;p&gt;There's a constraint underneath this. If a write learns its revision when it writes, and commits at some unknown later moment, then every other writer that commits in between has to be either blocked or hidden from readers until it ends. The only ways out are to drop the total order, or to not decide the number until commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options, and a spike to pick one
&lt;/h2&gt;

&lt;p&gt;I built a throwaway Python harness against Postgres 17.11 in Docker. Eight writer threads run on their own connections, and half their transactions stay open 0 to 30 ms after the write to force commits out of order. A ninth writer holds a transaction open for 3 s every 4 s, standing in for a slow request. Four readers resolve the latest revision and fingerprint what they see inside one REPEATABLE READ snapshot. The oracle compares each observation with the final state at the same revision, so any write that appeared under an observed head counts as a violation.&lt;/p&gt;

&lt;p&gt;The main run, at READ COMMITTED for 20 seconds (compare the rows with each other, not as absolute numbers):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scheme&lt;/th&gt;
&lt;th&gt;Writes/s&lt;/th&gt;
&lt;th&gt;Violations&lt;/th&gt;
&lt;th&gt;Writer wait p99&lt;/th&gt;
&lt;th&gt;Visibility lag p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No ordering (control)&lt;/td&gt;
&lt;td&gt;807&lt;/td&gt;
&lt;td&gt;39,355 of 39,371&lt;/td&gt;
&lt;td&gt;5.0 ms&lt;/td&gt;
&lt;td&gt;1.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock held to commit&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3,068 ms&lt;/td&gt;
&lt;td&gt;1.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequence + watermark&lt;/td&gt;
&lt;td&gt;643&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9.2 ms&lt;/td&gt;
&lt;td&gt;2,920 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Assign at commit&lt;/td&gt;
&lt;td&gt;686&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6.1 ms&lt;/td&gt;
&lt;td&gt;1.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lock held to commit&lt;/strong&gt; is SQLite's approach moved to Postgres. It's correct, but every writer waits out the slow transaction, and taking a store-wide lock in the middle of the app's transaction invites deadlocks. SpiceDB's Postgres datastore worked this way before v1.14.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sequence plus watermark&lt;/strong&gt; lets writers draw freely, and a reader's head is the highest revision below the oldest one still in flight. Writers don't wait, but head freezes behind the slow transaction, so for about 3 seconds nobody sees anyone's writes. Finding what's in flight means scanning &lt;code&gt;pg_locks&lt;/code&gt; on every read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assign at commit&lt;/strong&gt; doesn't decide the number until the transaction commits. The slow transaction holds the lock only during its own commit, so it blocks nobody and hides nobody.&lt;/p&gt;

&lt;p&gt;The fourth option is what SpiceDB does today: store transaction ids (&lt;code&gt;xid8&lt;/code&gt;) on rows and make a revision a Postgres snapshot. It's correct, but a revision stops being a &lt;code&gt;u64&lt;/code&gt; in a total order, which is the largest API change pemba could make. Worse, pemba's tables live in your application's database, so they get dumped, restored and logically replicated with it, and stored transaction ids mean nothing after any of those. SpiceDB owns its database and ships a repair command. pemba can't ask every app's restore to run one.&lt;/p&gt;

&lt;p&gt;Assigning at commit has a cost too. A write inside your transaction can't know its revision until that transaction commits, so &lt;code&gt;write(conn=)&lt;/code&gt; now returns a &lt;code&gt;PendingRevision&lt;/code&gt; that resolves after commit. It does on SQLite as well, so portable code never checks which store it's on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design that shipped
&lt;/h2&gt;

&lt;p&gt;Each write call draws a batch id, which has no ordering meaning. Rows it creates carry the id in &lt;code&gt;created_batch&lt;/code&gt;, rows it deletes carry it in &lt;code&gt;deleted_batch&lt;/code&gt;, and the call inserts one row into &lt;code&gt;pemba_pending&lt;/code&gt;. A deferred constraint trigger on that table fires at COMMIT, inside the committing transaction. It takes a store-wide advisory lock, draws the next revision with &lt;code&gt;nextval()&lt;/code&gt;, and inserts one row mapping batch to revision. That's all it does.&lt;/p&gt;

&lt;p&gt;A simplified version of the migration (the real one schema-qualifies every name, pins &lt;code&gt;search_path&lt;/code&gt;, and detects when the trigger fires early):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;pemba_revisions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;batch_id&lt;/span&gt;     &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rev&lt;/span&gt;          &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;committed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;pemba_assign_revision&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1885695330&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'pemba_revisions'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;int4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;pemba_revisions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;committed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;batch_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nextval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pemba_rev_seq'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;pemba_assign_revision&lt;/span&gt;
    &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pemba_pending&lt;/span&gt;
    &lt;span class="k"&gt;DEFERRABLE&lt;/span&gt; &lt;span class="k"&gt;INITIALLY&lt;/span&gt; &lt;span class="k"&gt;DEFERRED&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;pemba_assign_revision&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering argument rests on one detail. A transaction-level advisory lock is released when the transaction ends, and Postgres makes a committed transaction visible before it releases its locks (&lt;code&gt;xact.c&lt;/code&gt;: "this must be done &lt;em&gt;before&lt;/em&gt; releasing locks we hold"). So when the next committer gets the lock and draws a higher number, the previous commit is already visible to it and to every later snapshot. Revision order is commit order.&lt;/p&gt;

&lt;p&gt;The lock is held from the trigger to the end of the commit. A 1,000-tuple batch commits in 0.95 ms, against 0.85 ms with no commit step at all. Reads at head need no map: open transactions are invisible, so &lt;code&gt;deleted_batch IS NULL&lt;/code&gt; means visible. Reads at an older revision join the map, which made a 500-row lookup 34% slower at p50 in the spike.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise: SERIALIZABLE
&lt;/h2&gt;

&lt;p&gt;The first version of the trigger did more. Rows carried a provisional mark, and the trigger rewrote them to the real revision at commit. Rewriting with a predicate &lt;code&gt;UPDATE&lt;/code&gt; made 45 to 58% of SERIALIZABLE attempts fail with 40001, because it took SIREAD locks on index pages every writer was inserting into. Rewriting by &lt;code&gt;ctid&lt;/code&gt; brought failures down to 2.7 to 3.4% of commits, and I thought that was mostly solved.&lt;/p&gt;

&lt;p&gt;It wasn't. With retries and backoff, 0.03 to 0.08% of transactions still failed every retry. The design review didn't accept a documented rate of lost transactions, and I agreed: a correct app that retries must not lose writes.&lt;/p&gt;

&lt;p&gt;Before trying a fix, I went back over the harness and found it wasn't measuring the design. Each writer resolved its revision in a new SERIALIZABLE transaction on its own connection. The comparison scheme did no such read, and the real design resolves on a separate engine connection in autocommit. Rerunning the old behaviour put 0.57 to 0.72% of transactions into a 40001, with 1 to 9 give-ups per run. Part of what I'd measured was my own harness.&lt;/p&gt;

&lt;p&gt;With the harness fixed, I compared the rewrite against the batch map, which rewrites nothing. Retrying up to 10 times with backoff, over four runs with and without the slow transaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the rewrite still lost 5 transactions, in 2 of the 4 runs.&lt;/li&gt;
&lt;li&gt;the batch map lost 0 of 71,585. Between 0.10 and 0.25% of its transactions hit a 40001, at or below the comparison scheme without a commit step (0.25 to 0.27%).&lt;/li&gt;
&lt;li&gt;a 1,000-row commit went from 5.3 ms to 0.95 ms, because the commit no longer touches the rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Living with it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A permanent concurrency test.&lt;/strong&gt; The spike became &lt;code&gt;revision_order.rs&lt;/code&gt;, a Rust port that runs in CI at READ COMMITTED, REPEATABLE READ and SERIALIZABLE, with a long transaction at least twice per run. It asserts zero violations and, at SERIALIZABLE, zero transactions that fail every retry. It also runs a negative control that allocates at write time on the same tables, and fails unless the control shows violations, so the oracle can't pass vacuously. One honest note: with a precondition read in one write in ten, this test sees about 1% 40001s for pemba against about 0.4% for the control. That's an open question in the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SET CONSTRAINTS ALL IMMEDIATE&lt;/code&gt;.&lt;/strong&gt; This fires the deferred trigger at that statement, so the transaction holds the store-wide lock until it ends and behaves like "lock held to commit". In the spike, doing it in the 3 s transaction dropped throughput from 686 to 371 writes/s. Django's &lt;code&gt;TestCase&lt;/code&gt; does it after every test. The trigger checks &lt;code&gt;current_query()&lt;/code&gt;, and if the statement isn't COMMIT it raises a WARNING naming it. Correctness holds either way. It's a throughput trap, and the README says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry with backoff.&lt;/strong&gt; Immediate retries lose transactions with or without pemba: up to 5 instant retries lost 2 to 6 per run with the batch map and 0 to 7 with the comparison scheme. The documented requirement is exponential backoff with jitter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round trips.&lt;/strong&gt; This is what I want to improve next. A check costs about 5 round trips plus 2 to 3 per level of hierarchy walked. On a CI runner where &lt;code&gt;SELECT 1&lt;/code&gt; took 146 µs, a direct hit took 755 µs at p50. A statement cache and batched writes already halved most figures, but what's left is mostly network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick it apart
&lt;/h2&gt;

&lt;p&gt;The Postgres store shipped in pemba 0.1.0-alpha.3. The full reasoning, with every spike table, is in &lt;a href="https://github.com/Joshua-Fields/pemba/blob/main/docs/10-postgres.md" rel="noopener noreferrer"&gt;docs/10-postgres.md&lt;/a&gt;, and the code is on &lt;a href="https://github.com/Joshua-Fields/pemba" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The design leans on a few Postgres behaviours: commit visibility before lock release, deferred trigger ordering, and how SSI treats one index insert per batch. If you know Postgres internals better than I do and see a corner where one of those doesn't hold, I'd like to hear about it. Open an issue.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://joshuafields.uk/blog/keeping-revisions-in-commit-order-postgres" rel="noopener noreferrer"&gt;joshuafields.uk&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>rust</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building pemba: An Embedded Zanzibar-Style Authorization Engine in Rust</title>
      <dc:creator>Joshua Fields</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:48:09 +0000</pubDate>
      <link>https://dev.to/joshua-fields-dev/building-pemba-an-embedded-zanzibar-style-authorization-engine-in-rust-100f</link>
      <guid>https://dev.to/joshua-fields-dev/building-pemba-an-embedded-zanzibar-style-authorization-engine-in-rust-100f</guid>
      <description>&lt;h2&gt;
  
  
  Why I wanted authorization inside the process
&lt;/h2&gt;

&lt;p&gt;My last post was about running SpiceDB as platform infrastructure, which is how I built Monstro's permissions system as its sole developer. For a lot of teams that is still the right shape. But SpiceDB, like OpenFGA, Ory Keto and Permify, is deployed as a separate service that the application calls over the network, and for small-to-medium applications that brings three costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operational cost:&lt;/strong&gt; another service to deploy, scale, secure and monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; every permission check is a network round trip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual writes:&lt;/strong&gt; relationships live in the authorization service and application data lives in the application database. Creating a document means writing to both, and a partial failure leaves them inconsistent. Teams build outbox patterns and reconciliation jobs to cope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third one is the one I kept coming back to. With two systems to keep in sync, there is always a window where a document exists that nobody can open.&lt;/p&gt;

&lt;p&gt;pemba is my attempt at removing that gap. It is an embedded, Zanzibar-style relationship-based access control (ReBAC) engine, written in Rust with Python bindings, that runs inside your process and keeps its data in your database. The one-line pitch in the README is "SQLite for authorisation". The schema language is a strict subset of SpiceDB's, so you can move to SpiceDB if you outgrow it, and I can differential-test against SpiceDB with identical schemas.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Joshua-Fields" rel="noopener noreferrer"&gt;
        Joshua-Fields
      &lt;/a&gt; / &lt;a href="https://github.com/Joshua-Fields/pemba" rel="noopener noreferrer"&gt;
        pemba
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Embedded Zanzibar-style authorization for Rust and Python. Permission writes that join your app's own Postgres or SQLite transaction.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/Joshua-Fields/pemba/main/assets/pemba-logo.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FJoshua-Fields%2Fpemba%2Fmain%2Fassets%2Fpemba-logo.png" alt="pemba logo: a fruit bat with an orange fox face hanging from a branch, a keyhole on its folded wings, beside the word pemba" width="420"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;pemba&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;pemba was formerly developed under the codename warden.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;pemba is an &lt;strong&gt;embedded, Zanzibar-style authorisation engine&lt;/strong&gt; written in Rust, with Python
bindings: relationship-based access control (ReBAC) that runs inside your process and keeps
its data in your database. Think "SQLite for authorisation".&lt;/p&gt;
&lt;p&gt;You describe your permission model in a schema (a strict subset of
&lt;a href="https://github.com/authzed/spicedb" rel="noopener noreferrer"&gt;SpiceDB&lt;/a&gt;'s schema language), write relationships such
as &lt;code&gt;document:readme#viewer@user:anne&lt;/code&gt;, and ask questions: &lt;em&gt;can Anne view the readme?&lt;/em&gt;
&lt;em&gt;which documents can Anne view?&lt;/em&gt;, &lt;em&gt;who can view the readme?&lt;/em&gt; Answers follow groups, folders
nested teams and exclusions, and every answer names the revision it was computed at.&lt;/p&gt;
&lt;p&gt;Because the engine is in-process, a relationship write can &lt;strong&gt;join your application's own
database transaction&lt;/strong&gt;: the row you insert and the relationship that grants access to it
commit or roll back together. There is no second system to keep in sync, so no outbox, no
reconciliation job…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Joshua-Fields/pemba" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;You describe the permission model in a schema. This is the example from the README's schema guide, with groups, nested folders and a ban list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;definition user {}

definition group {
    relation member: user | group#member
}

definition folder {
    relation parent: folder
    relation owner: user
    relation viewer: user | user:* | group#member

    permission edit = owner + parent-&amp;gt;edit
    permission view = viewer + edit + parent-&amp;gt;view
}

definition document {
    relation folder: folder
    relation owner: user
    relation viewer: user | user:* | group#member
    relation banned: user

    permission edit = owner + folder-&amp;gt;edit
    permission view = (viewer + edit + folder-&amp;gt;view) - banned
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you write relationships and ask questions. From Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# or Engine.memory()
&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authz.zed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;rev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;touch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document:readme#viewer@user:anne&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document:readme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;view&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:anne&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at_least&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every write commits at a new revision, one counter per store. Every read resolves its consistency to exactly one revision when it starts and reads only that snapshot, so one answer never mixes two points in time. Passing the revision a write returned as &lt;code&gt;at_least=&lt;/code&gt; gives you read-your-writes when another process or connection did the write.&lt;/p&gt;

&lt;p&gt;The design rule for the schema language is that every schema pemba accepts must parse in SpiceDB and mean the same thing there. Where SpiceDB is surprising, such as &lt;code&gt;a + b &amp;amp; c&lt;/code&gt; meaning &lt;code&gt;(a + b) &amp;amp; c&lt;/code&gt;, pemba rejects rather than diverges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline feature: writes that join your transaction
&lt;/h2&gt;

&lt;p&gt;pemba's tables live in your database, prefixed &lt;code&gt;pemba_&lt;/code&gt;. Because the engine is in-process, a relationship write can join a transaction you already opened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BEGIN IMMEDIATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO documents(id, title) VALUES (?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Readme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;rev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;touch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document:readme#owner@user:anne&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMMIT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That example runs verbatim in CI. pemba neither commits nor rolls back. The returned revision becomes readable when you commit and never exists if you roll back. If the connection has no open transaction, the write raises &lt;code&gt;ValueError&lt;/code&gt; unless you pass &lt;code&gt;allow_autocommit=True&lt;/code&gt;, because otherwise it would not be atomic with your data.&lt;/p&gt;

&lt;p&gt;Getting this right was most of the work. All write logic (validation, precondition reads, revision allocation, row inserts and updates) is implemented once in Rust, generic over a small &lt;code&gt;WriteTxn&lt;/code&gt; trait with three methods: &lt;code&gt;execute&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt; and &lt;code&gt;last_insert_id&lt;/code&gt;. One implementation drives the engine's own connection. The other is an adapter in the Python binding that forwards the SQL to the caller's connection, so it runs inside the app's open transaction. A few of the details that came out of building it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Savepoints.&lt;/strong&gt; The write runs inside &lt;code&gt;SAVEPOINT pemba_write&lt;/code&gt; and rolls back to it on error. Without that, an &lt;code&gt;AlreadyExists&lt;/code&gt; on the third operation of a batch would leave a revision row and two operations behind for a caller who commits anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same-file check.&lt;/strong&gt; The engine checks, on every write, that the caller's connection points at the same database file. I'd planned to cache this by &lt;code&gt;id(conn)&lt;/code&gt;, but CPython reuses the &lt;code&gt;id()&lt;/code&gt; of a collected object, so a connection to a different file could inherit a stale "verified" entry and the write would land in a database the engine never reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revision order equals commit order.&lt;/strong&gt; SQLite allows one writer at a time, so revisions can't interleave. If a deferred transaction reads a precondition and another connection commits first, SQLite refuses the lock upgrade with &lt;code&gt;SQLITE_BUSY_SNAPSHOT&lt;/code&gt;, and pemba surfaces a retryable conflict rather than committing on a stale precondition.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  War story one: two copies of SQLite in one process
&lt;/h2&gt;

&lt;p&gt;The first design said that reads use the engine's own connections. In Python, those were &lt;code&gt;rusqlite&lt;/code&gt; connections with a bundled SQLite, while the app's &lt;code&gt;sqlite3&lt;/code&gt; module used CPython's own copy. Foreign-transaction mode put both copies on one file in one process, by design.&lt;/p&gt;

&lt;p&gt;That turns out to be dangerous. On POSIX, &lt;code&gt;close()&lt;/code&gt; drops every &lt;code&gt;fcntl&lt;/code&gt; lock the process holds on a file. SQLite works around this by keeping a process-wide list of open files, but each copy of SQLite has its own list, so a close by one copy silently drops the other copy's locks.&lt;/p&gt;

&lt;p&gt;I wrote a deterministic probe to see whether this was real. A bundled-copy connection holds &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; and an uncommitted insert on a WAL database. A separate process tries &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; and fails, as it should. Then, in the first process, Python runs &lt;code&gt;sqlite3.connect&lt;/code&gt;, one &lt;code&gt;SELECT&lt;/code&gt; and &lt;code&gt;close()&lt;/code&gt;, and the separate process tries again.&lt;/p&gt;

&lt;p&gt;The bug was real. The separate process got the write lock and committed, and the bundled connection committed too, believing it had held the lock throughout. Python's close also checkpointed and deleted the &lt;code&gt;-wal&lt;/code&gt; and &lt;code&gt;-shm&lt;/code&gt; files under the open writer. Afterwards &lt;code&gt;PRAGMA integrity_check&lt;/code&gt; said &lt;code&gt;ok&lt;/code&gt;, but the other process's committed row was gone. That is silent loss of a committed transaction, which is worse than a crash. It reproduced locally on WSL2 Ubuntu and in all four CI jobs on Linux and macOS. Windows locks per handle and doesn't have this mechanism, and I didn't test it there.&lt;/p&gt;

&lt;p&gt;One way out was to link the extension against the interpreter's SQLite. It can't be made reliable for PyPI wheels: a manylinux wheel may not depend on &lt;code&gt;libsqlite3.so.0&lt;/code&gt;, python.org macOS builds compile SQLite into the interpreter, and equal version strings prove nothing, because two copies of 3.45.1 still corrupt each other.&lt;/p&gt;

&lt;p&gt;The other was a store whose every statement (reads, native writes, migrations, GC) runs on Python &lt;code&gt;sqlite3&lt;/code&gt; connections the engine owns, so only CPython's copy ever touches the file. All authorization logic stays in Rust behind the same traits. The prototype passed all 25 cases of the store conformance suite, and then I measured what it cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single-threaded, a check went from 36 µs to about 100 µs at p50, roughly 2.7×.&lt;/li&gt;
&lt;li&gt;With 8 threads and no serialization it collapsed to 190 checks per second, because &lt;code&gt;sqlite3&lt;/code&gt; releases the GIL inside every &lt;code&gt;execute&lt;/code&gt; and the threads convoy on it.&lt;/li&gt;
&lt;li&gt;Holding the GIL for a whole check deadlocked once threads outnumbered pooled connections.&lt;/li&gt;
&lt;li&gt;Serializing requests (take a Rust mutex without the GIL, then hold the GIL for the whole request) gave 6,700 checks per second, with p99 at 620 µs against the native store's 1,440 µs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went with the Python-owned store, serialized. &lt;code&gt;Engine.sqlite()&lt;/code&gt; now goes through CPython's own &lt;code&gt;sqlite3&lt;/code&gt;, the Python package no longer depends on &lt;code&gt;rusqlite&lt;/code&gt;, and CI fails if the extension or the wheel contains SQLite. The probe became a permanent test, and the README warns against opening the same database with anything that brings its own SQLite, such as &lt;code&gt;apsw&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The lesson I took from it: "the engine's own connections" had quietly assumed there was only one SQLite library in the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  War story two: exponential checks on cyclic data
&lt;/h2&gt;

&lt;p&gt;Cycles in data are legal. Groups contain each other and folders point at each other. pemba evaluates them to a fixed point, so a group that contains itself is a finite question (SpiceDB keeps dispatching until it reports &lt;code&gt;max depth exceeded&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The original rule was simple: a node currently being evaluated counts as &lt;code&gt;false&lt;/code&gt; for the purpose of breaking the cycle, and any result that depended on that assumption is not memoised. That is correct, but inside a cyclic region it re-walks every path. Cyclic group data is ordinary data that users can write, so a user could make one &lt;code&gt;check&lt;/code&gt; arbitrarily expensive. That's a denial-of-service risk, and neither &lt;code&gt;max_depth&lt;/code&gt; nor the fan-out limit bounds total work.&lt;/p&gt;

&lt;p&gt;The property tests found it. One 1,000-case run took 3.0 seconds. The next, with a different random seed, was still running after 573 seconds when I stopped it. The pinned case had two types and 30 tuples with dense mutual recursion over ids &lt;code&gt;a&lt;/code&gt; to &lt;code&gt;f&lt;/code&gt;. One &lt;code&gt;check&lt;/code&gt; made 32,726 store reads at depth 29, about 170 ms in release, while the reference evaluator answered the whole case in 21 ms.&lt;/p&gt;

&lt;p&gt;My first fix, adding lowlink taint and memoising cycle heads, was still exponential. On a complete group graph with the subject in no group, store reads for n = 5 to 9 groups were 130, 652, 3,914, 27,400 and 219,202. The evaluator was walking every simple path, around (n−1)! of them.&lt;/p&gt;

&lt;p&gt;The fix that worked is SCC fixed-point iteration, Tarjan-style. Each node of a strongly connected region is evaluated at most once per pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A hit on a node still in progress returns its provisional value (&lt;code&gt;false&lt;/code&gt; if it has none) and marks it as assumed.&lt;/li&gt;
&lt;li&gt;When the head of the region finishes, if no assumed &lt;code&gt;false&lt;/code&gt; finished &lt;code&gt;true&lt;/code&gt;, the pass is consistent and every value in the region is memoised.&lt;/li&gt;
&lt;li&gt;Otherwise the provisional values are raised and the head re-runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Provisional values only grow, and a region of |S| nodes needs at most |S| + 1 passes, so one &lt;code&gt;check&lt;/code&gt; is bounded at &lt;code&gt;(|S| + 1) · Σ r(x)&lt;/code&gt; store reads per region, where &lt;code&gt;r(x)&lt;/code&gt; is the reads one evaluation of a node makes. The complete group graph that blew up before now takes one pass and about 2n reads. One subtle bug on the way: lowlinks have to be SCC-stack positions, not call-stack indices, or a sibling that reuses a popped frame's index looks like the head of its own region and settles a &lt;code&gt;false&lt;/code&gt; too early.&lt;/p&gt;

&lt;p&gt;A cycle that crosses the subtracted side of an exclusion has no well-defined least fixed point, so pemba returns &lt;code&gt;NonMonotonicCycle&lt;/code&gt; instead of guessing. And there's a backstop. &lt;code&gt;max_check_reads&lt;/code&gt; (default 200,000) caps store reads per check and fails closed with &lt;code&gt;ReadLimitExceeded&lt;/code&gt;. It guards against bugs in the rule and against large but legitimate graphs. At an estimated 5 to 20 µs per indexed SQLite point read, which I haven't measured yet, that caps a pathological check at roughly 1 to 4 seconds. After the change, the pinned case makes well under its 1,000-read bound and the 1,000-case property run finishes in about a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I check that it's right
&lt;/h2&gt;

&lt;p&gt;Authorization bugs that grant too much are the ones that matter, so most of the effort went into verification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A reference evaluator:&lt;/strong&gt; a deliberately naive, obviously-correct evaluator used only in tests as an oracle. Even the oracle needed fixing: with recursion on an exclusion's base side, one variant of its iteration stopped on a wrong answer that looked like a fixed point. It now stratifies the schema and declines what it can't stratify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Property tests:&lt;/strong&gt; &lt;code&gt;check&lt;/code&gt; against the oracle on generated schemas and data, with the 1,000-case run as a separate workflow; store equivalence across the memory and SQLite stores; and the formatter, which must keep every comment exactly once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Differential tests against SpiceDB 1.56.2:&lt;/strong&gt; 18 fixtures covering 23,621 questions and 500 compared generated cases covering 499,517 questions, with zero unexplained mismatches. Every known difference has a detector that needs the exact shape of the disagreement, so it can't hide an unrelated bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuzz targets&lt;/strong&gt;, including one that runs the property-test generators under AddressSanitizer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generated cases sent to SpiceDB have no cycles and at most 16 relationships, because on the densest data one SpiceDB bulk check used 2.5 GB and the test server was OOM-killed on a 4 GB machine. Cyclic and larger data go through the fixtures.&lt;/p&gt;

&lt;p&gt;The most interesting difference is KD-3. In pemba, a stored wildcard &lt;code&gt;t:*&lt;/code&gt; matches every object of type &lt;code&gt;t&lt;/code&gt; and never a subject set like &lt;code&gt;t:x#r&lt;/code&gt;. SpiceDB matches the subject set too. On the granting side that makes pemba the stricter one, and I kept that. On the subtracted side of an exclusion it made pemba the more permissive one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;definition user {}
definition grp { relation member: user }
definition doc {
    relation viewer: grp#member
    relation banned: grp:*
    permission view = viewer - banned
}

doc:d#viewer@grp:g#member
doc:d#banned@grp:*
check doc:d#view@grp:g#member     -&amp;gt; pemba AMBIGUOUS_WILDCARD_EXCLUSION (was true), SpiceDB false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the fix, pemba allowed what SpiceDB denied. That is the fail-open direction, so pemba now refuses to answer there and returns &lt;code&gt;AMBIGUOUS_WILDCARD_EXCLUSION&lt;/code&gt;. The reasoning, from the evaluation spec: an error can later become an answer without breaking callers, but an answer cannot change silently.&lt;/p&gt;

&lt;p&gt;That doesn't mean every disagreement becomes an error. Where the difference is pemba's documented semantics, I kept them: on cyclic data pemba answers and SpiceDB errors (KD-1), and near the depth limit of 50 SpiceDB's answer depends on timing while pemba's is deterministic (KD-4). More generally, evaluation never turns a store error, a depth limit or an undefined cycle into &lt;code&gt;True&lt;/code&gt; or &lt;code&gt;False&lt;/code&gt;. It raises, and the caller decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;pemba is pre-alpha. &lt;code&gt;check&lt;/code&gt;, &lt;code&gt;lookup_resources&lt;/code&gt;, &lt;code&gt;lookup_subjects&lt;/code&gt;, the memory and SQLite stores, transactional writes and the Python bindings are implemented and tested, including against SpiceDB. Nothing is published to PyPI or crates.io yet, the 0.1 release gate isn't signed off, and you should expect breaking changes.&lt;/p&gt;

&lt;p&gt;The biggest gap is performance. A benchmark harness exists, sharing one set of datasets across the Rust, Python and SpiceDB benches, but no benchmark has been run and no targets are set. The numbers in this post come from probes and tests, not benchmarks. The fuzz targets exist, but the 10-minute runs and mutation testing haven't happened. Postgres is planned, not started.&lt;/p&gt;

&lt;p&gt;If many services in many languages need the same permissions, or you need multi-region scale or a mature product today, use SpiceDB. pemba is for a single application, or a few services sharing one database, that wants fine-grained sharing without running another service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "pemba"
&lt;/h2&gt;

&lt;p&gt;The project started under the codename warden. pemba comes from Pemba Island in the Zanzibar archipelago, a nod to Google's Zanzibar paper, and home of the Pemba flying fox, which lives nowhere else.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Feedback and issues are very welcome on &lt;a href="https://github.com/Joshua-Fields/pemba" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you've run into the dual-write problem with an external authorization service, I'd like to hear how you handled it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>python</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Designing Explainable AI Matchmaking Systems</title>
      <dc:creator>Joshua Fields</dc:creator>
      <pubDate>Wed, 24 Jun 2026 13:22:31 +0000</pubDate>
      <link>https://dev.to/joshua-fields-dev/designing-explainable-ai-matchmaking-systems-3gig</link>
      <guid>https://dev.to/joshua-fields-dev/designing-explainable-ai-matchmaking-systems-3gig</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjet0zi7ruo49ml8a7a9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjet0zi7ruo49ml8a7a9.png" alt="Abstract cover image for a blog post titled “Designing Explainable AI Matchmaking Systems,” showing an AI-inspired matchmaking concept with connected profiles, data points, and a technical visual style.&lt;br&gt;
" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Matchmaking systems are often framed as an algorithm challenge.&lt;/p&gt;

&lt;p&gt;In practice, they are a trust challenge.&lt;/p&gt;

&lt;p&gt;People are making personal decisions. If they cannot understand why a system suggested someone, confidence drops quickly. That is why I think explainability is not an optional nice-to-have in compatibility products. It is part of the product itself.&lt;/p&gt;

&lt;p&gt;This is how I think about designing AI-assisted matchmaking systems from an engineering and product perspective: conversational onboarding, profile enrichment from transcripts, deterministic scoring, privacy controls, and UX that explains outcomes without overwhelming users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why deterministic logic matters
&lt;/h2&gt;

&lt;p&gt;I am not anti-ML.&lt;/p&gt;

&lt;p&gt;I use AI where it adds leverage. But for compatibility scoring in user-facing matchmaking, fully black-box ranking can create more product risk than product value.&lt;/p&gt;

&lt;p&gt;If two users ask, “Why did we match?”, the answer cannot be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The model thought so.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not enough.&lt;/p&gt;

&lt;p&gt;For this kind of product, I prefer a hybrid approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use LLMs to structure messy user inputs into clear profile features&lt;/li&gt;
&lt;li&gt;Use deterministic rules and weighted scoring for compatibility&lt;/li&gt;
&lt;li&gt;Use transparent explanations generated from explicit matching factors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives you consistency, easier debugging, and safer iteration.&lt;/p&gt;

&lt;p&gt;Product teams can tune outcomes intentionally instead of nudging prompts and hoping ranking behaviour stabilizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversational onboarding as data collection
&lt;/h2&gt;

&lt;p&gt;Static forms are quick to build, but they are often poor at extracting real preference signals.&lt;/p&gt;

&lt;p&gt;Conversational onboarding can collect richer context, especially when users are not sure how to describe what they want. But the trick is keeping it structured underneath.&lt;/p&gt;

&lt;p&gt;A good onboarding flow should feel conversational to the user, but still produce clean data for the system.&lt;/p&gt;

&lt;p&gt;I usually think about onboarding in clear intent categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;values&lt;/li&gt;
&lt;li&gt;lifestyle constraints&lt;/li&gt;
&lt;li&gt;relationship expectations&lt;/li&gt;
&lt;li&gt;communication style&lt;/li&gt;
&lt;li&gt;hard boundaries&lt;/li&gt;
&lt;li&gt;location and availability&lt;/li&gt;
&lt;li&gt;dealbreakers&lt;/li&gt;
&lt;li&gt;preferences that are flexible rather than absolute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each answer can be captured as raw text first, then normalized into structured slots.&lt;/p&gt;

&lt;p&gt;The user feels like they are having a guided conversation, while the backend receives clean inputs for scoring.&lt;/p&gt;

&lt;p&gt;The biggest mistake is letting conversation transcripts become the source of truth by themselves.&lt;/p&gt;

&lt;p&gt;Raw transcripts are noisy. They contain uncertainty, contradictions, phrasing issues, and sometimes throwaway comments. Before they can power matching logic, you need explicit feature extraction and validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transcript-driven profile enrichment
&lt;/h2&gt;

&lt;p&gt;Once transcripts exist, an enrichment pipeline can extract structured profile attributes.&lt;/p&gt;

&lt;p&gt;A simple workflow might look like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A transcript segment arrives&lt;/li&gt;
&lt;li&gt;An extractor job parses candidate traits and preferences&lt;/li&gt;
&lt;li&gt;Validation checks confidence and conflicts&lt;/li&gt;
&lt;li&gt;Approved updates are written to a profile feature store&lt;/li&gt;
&lt;li&gt;The user is asked to confirm important or sensitive changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each extracted field should track provenance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which transcript segment produced it&lt;/li&gt;
&lt;li&gt;confidence level&lt;/li&gt;
&lt;li&gt;last update timestamp&lt;/li&gt;
&lt;li&gt;whether the user confirmed it&lt;/li&gt;
&lt;li&gt;whether it conflicts with another known preference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because people change their minds and sometimes express things unclearly.&lt;/p&gt;

&lt;p&gt;For example, a user might say they are open to relocating in one session, then later say they are definitely not moving.&lt;/p&gt;

&lt;p&gt;The system should not silently overwrite the old field.&lt;/p&gt;

&lt;p&gt;It should show a conflict and request confirmation.&lt;/p&gt;

&lt;p&gt;That kind of behaviour makes the product feel more trustworthy because the user can see the system is not pretending to understand more than it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility scoring design
&lt;/h2&gt;

&lt;p&gt;I like scoring systems that separate hard filters from soft preferences.&lt;/p&gt;

&lt;p&gt;Hard filters are non-negotiables. They might include things like distance limits, age bounds, or specific life goals.&lt;/p&gt;

&lt;p&gt;Soft preferences influence ranking but do not immediately exclude candidates.&lt;/p&gt;

&lt;p&gt;A practical scoring model might include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hard eligibility gate: pass or fail&lt;/li&gt;
&lt;li&gt;weighted dimensions: values alignment, communication fit, lifestyle overlap&lt;/li&gt;
&lt;li&gt;penalties for unresolved conflicts or sparse profile data&lt;/li&gt;
&lt;li&gt;confidence multiplier based on onboarding depth&lt;/li&gt;
&lt;li&gt;explanation output showing the main positive and negative factors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the final score interpretable.&lt;/p&gt;

&lt;p&gt;You can expose top contributing factors directly in the UI, and product teams can tune weights with controlled experiments rather than retraining opaque models every time requirements shift.&lt;/p&gt;

&lt;p&gt;That is the part I care about most.&lt;/p&gt;

&lt;p&gt;The system should be something engineers, product people, and users can reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explainability UX patterns that work
&lt;/h2&gt;

&lt;p&gt;Explainability is not just a backend feature.&lt;/p&gt;

&lt;p&gt;It has to be visible and legible in the product.&lt;/p&gt;

&lt;p&gt;I usually think about explanations in three layers:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Quick reason
&lt;/h3&gt;

&lt;p&gt;A one-sentence summary.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Strong alignment on communication style and long-term goals.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This gives the user an immediate reason without forcing them through a technical breakdown.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Factor breakdown
&lt;/h3&gt;

&lt;p&gt;Show the top 3–5 positive and negative contributors.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;similar communication preferences&lt;/li&gt;
&lt;li&gt;overlapping lifestyle expectations&lt;/li&gt;
&lt;li&gt;compatible location range&lt;/li&gt;
&lt;li&gt;mismatch on long-term travel preferences&lt;/li&gt;
&lt;li&gt;low confidence because one profile is incomplete&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the result feel explainable without reducing everything to a single score.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Preference controls
&lt;/h3&gt;

&lt;p&gt;Users should be able to adjust what matters and see how it affects suggestions.&lt;/p&gt;

&lt;p&gt;If someone says location matters less than values, the product should let them express that.&lt;/p&gt;

&lt;p&gt;This is where explainability becomes interactive rather than just descriptive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety, moderation, and abuse controls
&lt;/h2&gt;

&lt;p&gt;Any social product needs abuse prevention from day one.&lt;/p&gt;

&lt;p&gt;Matchmaking is no exception.&lt;/p&gt;

&lt;p&gt;I separate safety controls into three layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;content safety checks for onboarding and messaging&lt;/li&gt;
&lt;li&gt;behavioural risk signals, such as spam patterns or repeated boundary violations&lt;/li&gt;
&lt;li&gt;human escalation paths for ambiguous or high-severity cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI can help classify risk, but I would avoid purely automated irreversible actions for edge cases.&lt;/p&gt;

&lt;p&gt;Human-in-the-loop review is still important when context is nuanced.&lt;/p&gt;

&lt;p&gt;Systems should optimize for user safety and fairness, not only engagement metrics.&lt;/p&gt;

&lt;p&gt;A matchmaking product that maximizes engagement at the expense of user trust is building the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy and data minimization
&lt;/h2&gt;

&lt;p&gt;Matchmaking data is highly sensitive.&lt;/p&gt;

&lt;p&gt;People may share relationship history, personal preferences, boundaries, family goals, lifestyle constraints, and location information.&lt;/p&gt;

&lt;p&gt;The safest data is often data you do not collect or do not retain long-term.&lt;/p&gt;

&lt;p&gt;Some practices I think are important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;separate personally identifying data from preference features&lt;/li&gt;
&lt;li&gt;store only features needed for matching and explanations&lt;/li&gt;
&lt;li&gt;support user-initiated deletion with clear propagation&lt;/li&gt;
&lt;li&gt;avoid exposing full transcript history in operational dashboards&lt;/li&gt;
&lt;li&gt;redact sensitive fields in logs&lt;/li&gt;
&lt;li&gt;use clear retention policies&lt;/li&gt;
&lt;li&gt;make it obvious what is used for matching and why&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not just compliance tasks.&lt;/p&gt;

&lt;p&gt;They are product trust features.&lt;/p&gt;

&lt;p&gt;If users do not trust how their data is handled, they will not trust the matches either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational architecture
&lt;/h2&gt;

&lt;p&gt;On the engineering side, a clean architecture for this domain might include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a profile service for structured attributes&lt;/li&gt;
&lt;li&gt;a transcript processing pipeline for enrichment jobs&lt;/li&gt;
&lt;li&gt;a scoring service with deterministic logic and versioned weights&lt;/li&gt;
&lt;li&gt;an explanation service that maps score components to readable output&lt;/li&gt;
&lt;li&gt;an audit layer for traceability and debugging&lt;/li&gt;
&lt;li&gt;a moderation layer for safety review and escalation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I prefer versioning scoring configurations so you can compare match outcomes over time and roll forward or back safely.&lt;/p&gt;

&lt;p&gt;This also makes experimentation cleaner because you can tie user cohorts to explicit scoring versions.&lt;/p&gt;

&lt;p&gt;If a change makes matches feel worse, you need to know exactly which scoring version caused it.&lt;/p&gt;

&lt;p&gt;Without versioning, it becomes hard to separate model behaviour, scoring logic, and product changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this maps to Needle-style products
&lt;/h2&gt;

&lt;p&gt;In products like Needle-style matchmaking experiences, conversational onboarding and explainable compatibility are especially important because users are often evaluating whether the product understands them at all.&lt;/p&gt;

&lt;p&gt;The first few suggestions become a credibility test.&lt;/p&gt;

&lt;p&gt;A practical flow might look like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Voice or chat onboarding captures intent and constraints&lt;/li&gt;
&lt;li&gt;LLM extraction creates structured profile candidates&lt;/li&gt;
&lt;li&gt;User confirms key fields before matching&lt;/li&gt;
&lt;li&gt;A deterministic scorer ranks candidates&lt;/li&gt;
&lt;li&gt;The UI explains each match with clear factor breakdowns&lt;/li&gt;
&lt;li&gt;Users adjust preferences over time&lt;/li&gt;
&lt;li&gt;The system learns from confirmed changes rather than guessing silently&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point is not to make the product feel like a spreadsheet.&lt;/p&gt;

&lt;p&gt;The point is to make the AI feel accountable.&lt;/p&gt;

&lt;p&gt;If the system recommends someone, the user should be able to understand the main reason.&lt;/p&gt;

&lt;p&gt;If the recommendation is weak, the system should be honest about that too.&lt;/p&gt;

&lt;p&gt;For the broader product context, I have a related project page here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://joshuafields.uk/work/needle" rel="noopener noreferrer"&gt;Needle AI matchmaking case study — coming soon&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to measure
&lt;/h2&gt;

&lt;p&gt;Explainable systems still need measurable outcomes.&lt;/p&gt;

&lt;p&gt;Some metrics I would track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;onboarding completion and drop-off by step&lt;/li&gt;
&lt;li&gt;profile completeness&lt;/li&gt;
&lt;li&gt;conflict resolution rate&lt;/li&gt;
&lt;li&gt;match acceptance rate after explanation shown&lt;/li&gt;
&lt;li&gt;user edits to preference weights over time&lt;/li&gt;
&lt;li&gt;safety intervention rates&lt;/li&gt;
&lt;li&gt;review turnaround time&lt;/li&gt;
&lt;li&gt;number of matches rejected because of unclear reasoning&lt;/li&gt;
&lt;li&gt;number of matches accepted after viewing explanation details&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If acceptance rises after users view explanations, you are probably on the right path.&lt;/p&gt;

&lt;p&gt;If users frequently override the same preference dimension, your weighting model likely needs adjustment.&lt;/p&gt;

&lt;p&gt;If users do not trust the explanation, the explanation is not doing its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Explainable AI matchmaking is not about reducing everything to a score.&lt;/p&gt;

&lt;p&gt;It is about giving users understandable, adjustable, and trustworthy outcomes.&lt;/p&gt;

&lt;p&gt;LLMs are useful for handling unstructured input. They can turn messy conversations into structured candidate features. But deterministic logic remains powerful where product trust and accountability matter most.&lt;/p&gt;

&lt;p&gt;From an engineering perspective, the best systems are the ones product, design, and backend teams can reason about together.&lt;/p&gt;

&lt;p&gt;If you can explain your architecture to both an engineer and a user in plain language, you are probably building the right thing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://joshuafields.uk/blog/designing-explainable-ai-matchmaking-systems" rel="noopener noreferrer"&gt;Original Post&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>product</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Building Real-Time Voice AI Applications with LiveKit and FastAPI</title>
      <dc:creator>Joshua Fields</dc:creator>
      <pubDate>Tue, 23 Jun 2026 10:40:54 +0000</pubDate>
      <link>https://dev.to/joshua-fields-dev/building-real-time-voice-ai-applications-with-livekit-and-fastapi-pae</link>
      <guid>https://dev.to/joshua-fields-dev/building-real-time-voice-ai-applications-with-livekit-and-fastapi-pae</guid>
      <description>&lt;p&gt;Real-time voice AI demos are easy to make look impressive.&lt;/p&gt;

&lt;p&gt;Production voice systems are a different problem.&lt;/p&gt;

&lt;p&gt;The demo usually has one clean interaction, one happy path, and no load. Production has packet jitter, user interruptions, reconnects, flaky speech recognition, delayed TTS, provider failures, and all the little timing issues that make a conversation feel robotic if you do not design for them upfront.&lt;/p&gt;

&lt;p&gt;This is how I think about building real-time voice AI applications with LiveKit and FastAPI in a way that can actually ship.&lt;/p&gt;

&lt;p&gt;It is less about one framework trick and more about architecture decisions: where state lives, where latency accumulates, how retries work, and what to observe before users tell you something feels off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference architecture at a glance
&lt;/h2&gt;

&lt;p&gt;A practical voice AI stack has a few clear layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt;: browser or mobile app that captures mic input, streams audio, and plays synthesized responses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice session layer&lt;/strong&gt;: session identity, auth tokens, connection lifecycle, and per-user context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiveKit room&lt;/strong&gt;: low-latency media transport and participant coordination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STT pipeline&lt;/strong&gt;: speech-to-text with partial and final transcripts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM orchestration&lt;/strong&gt;: prompt construction, tool calls, memory policy, and response shaping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTS pipeline&lt;/strong&gt;: text-to-speech chunks streamed back to the user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend APIs&lt;/strong&gt;: FastAPI services handling session state, business actions, and persistence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: metrics, traces, logs, and replay signals for latency and failure analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I try to keep each layer independently testable.&lt;/p&gt;

&lt;p&gt;If the orchestration logic can only run when a full audio room is active, debugging becomes painful fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client and session boundaries
&lt;/h2&gt;

&lt;p&gt;The client should do as little product logic as possible.&lt;/p&gt;

&lt;p&gt;It captures audio, handles UI state, and relays events. It should not decide authorization or business outcomes.&lt;/p&gt;

&lt;p&gt;For every voice session, I prefer generating a short-lived token on the backend, scoped to a single room and participant role. That keeps room access controlled and avoids broad credentials leaking into the frontend.&lt;/p&gt;

&lt;p&gt;I also keep a server-side session record keyed by a stable session ID.&lt;/p&gt;

&lt;p&gt;That record can track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;user ID, if authenticated&lt;/li&gt;
&lt;li&gt;room ID&lt;/li&gt;
&lt;li&gt;started timestamp&lt;/li&gt;
&lt;li&gt;current mode&lt;/li&gt;
&lt;li&gt;latest orchestration state&lt;/li&gt;
&lt;li&gt;current turn state&lt;/li&gt;
&lt;li&gt;reconnect status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a user reconnects, the backend can recover context without guessing from client memory.&lt;/p&gt;

&lt;p&gt;That matters because voice sessions are not just request-response flows. They are live interactions with timing, interruptions, and state.&lt;/p&gt;

&lt;h2&gt;
  
  
  LiveKit room design decisions
&lt;/h2&gt;

&lt;p&gt;LiveKit gives you the real-time substrate, but you still need conventions.&lt;/p&gt;

&lt;p&gt;For most assistant-style experiences, I prefer one room per active user session. It keeps event scopes clear and reduces accidental cross-talk.&lt;/p&gt;

&lt;p&gt;If the use case requires multiple participants, such as a user, an AI agent, and a supervisor, define explicit participant metadata and role handling early.&lt;/p&gt;

&lt;p&gt;Two patterns help a lot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use data channels for control events like interrupt, mute, handoff, and system status&lt;/li&gt;
&lt;li&gt;Treat room events as first-class telemetry, not just infrastructure logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Join, leave, track publish, track unpublish, reconnect, and bitrate drops are all product signals.&lt;/p&gt;

&lt;p&gt;That event data becomes crucial when someone says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The assistant started speaking over me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At that point, you need to know whether the issue was model latency, voice activity detection timing, TTS cancellation, or a reconnect edge case.&lt;/p&gt;

&lt;h2&gt;
  
  
  STT: partials, finals, and confidence handling
&lt;/h2&gt;

&lt;p&gt;Speech-to-text should emit partial transcripts quickly for responsiveness.&lt;/p&gt;

&lt;p&gt;But downstream business logic should usually wait for final segments or confidence thresholds.&lt;/p&gt;

&lt;p&gt;If you run every partial transcript through your orchestration loop, you create race conditions and noisy model calls.&lt;/p&gt;

&lt;p&gt;I usually think about transcript events in explicit states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;partial&lt;/strong&gt;: render to UI, but do not commit to durable context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;final&lt;/strong&gt;: append to conversation history and trigger orchestration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;revised final&lt;/strong&gt;: patch a previous segment if the provider corrects recognition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes transcript behaviour deterministic and easier to test.&lt;/p&gt;

&lt;p&gt;It also avoids subtle bugs where the assistant answers a phrase the user never actually said in the final transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM orchestration in FastAPI
&lt;/h2&gt;

&lt;p&gt;For orchestration, I usually expose a FastAPI endpoint or event handler that receives normalized transcript events and returns structured actions rather than raw prose.&lt;/p&gt;

&lt;p&gt;The action envelope might include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;assistant text&lt;/li&gt;
&lt;li&gt;tool calls&lt;/li&gt;
&lt;li&gt;state updates&lt;/li&gt;
&lt;li&gt;UI directives&lt;/li&gt;
&lt;li&gt;follow-up prompts&lt;/li&gt;
&lt;li&gt;confirmation requests&lt;/li&gt;
&lt;li&gt;escalation signals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When teams skip this structure, the orchestration layer turns into prompt glue and ad-hoc branching.&lt;/p&gt;

&lt;p&gt;I prefer strict schemas, even when the model itself is flexible.&lt;/p&gt;

&lt;p&gt;With schema-first orchestration, you can validate outputs, reject malformed actions, and retry safely without duplicating side effects.&lt;/p&gt;

&lt;p&gt;This is where FastAPI works well for me: clear request models, async handling, and a straightforward way to compose tool integrations while keeping the contract explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency budgets and interruption behaviour
&lt;/h2&gt;

&lt;p&gt;Voice UX is mostly latency UX.&lt;/p&gt;

&lt;p&gt;If a response arrives late, users interrupt.&lt;/p&gt;

&lt;p&gt;If interruption handling is weak, trust drops quickly.&lt;/p&gt;

&lt;p&gt;I like setting a practical latency budget per turn and breaking it down by stage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;STT latency&lt;/li&gt;
&lt;li&gt;orchestration latency&lt;/li&gt;
&lt;li&gt;tool call latency&lt;/li&gt;
&lt;li&gt;TTS startup time&lt;/li&gt;
&lt;li&gt;time to first audio byte&lt;/li&gt;
&lt;li&gt;total turn completion time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you have per-stage timings, optimization decisions become much clearer.&lt;/p&gt;

&lt;p&gt;Interruption should be supported as a first-class control flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;client sends interrupt event immediately&lt;/li&gt;
&lt;li&gt;current TTS stream is cancelled&lt;/li&gt;
&lt;li&gt;orchestrator marks the prior response as interrupted&lt;/li&gt;
&lt;li&gt;next turn continues from a clean state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without explicit cancellation semantics, the system often continues generating text in the background and then leaks stale context into the next turn.&lt;/p&gt;

&lt;p&gt;That is one of the fastest ways to make a voice assistant feel broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries and idempotency
&lt;/h2&gt;

&lt;p&gt;Retries are inevitable with external STT, LLM, and TTS providers.&lt;/p&gt;

&lt;p&gt;The critical point is making retries safe.&lt;/p&gt;

&lt;p&gt;I like attaching idempotency keys to orchestration turns and tool executions, then persisting turn state transitions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;received&lt;/li&gt;
&lt;li&gt;processing&lt;/li&gt;
&lt;li&gt;completed&lt;/li&gt;
&lt;li&gt;failed&lt;/li&gt;
&lt;li&gt;cancelled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a timeout occurs and the client retries, the backend can return an existing result or resume from a known step instead of replaying side effects.&lt;/p&gt;

&lt;p&gt;This matters a lot when tool calls trigger real actions, such as booking, messaging, account changes, database writes, or payment-related workflows.&lt;/p&gt;

&lt;p&gt;A voice system should feel conversational, but the backend still needs to behave like a reliable distributed system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability that actually helps
&lt;/h2&gt;

&lt;p&gt;For voice systems, aggregate uptime is not enough.&lt;/p&gt;

&lt;p&gt;I care about metrics that explain the actual user experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;end-to-end turn latency percentiles&lt;/li&gt;
&lt;li&gt;time to first transcript token&lt;/li&gt;
&lt;li&gt;time to final transcript&lt;/li&gt;
&lt;li&gt;time to first audio byte from TTS&lt;/li&gt;
&lt;li&gt;interrupt rate per session&lt;/li&gt;
&lt;li&gt;provider error codes by stage&lt;/li&gt;
&lt;li&gt;reconnect frequency&lt;/li&gt;
&lt;li&gt;average recovery time&lt;/li&gt;
&lt;li&gt;cancelled turn count&lt;/li&gt;
&lt;li&gt;failed orchestration count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also like structured logs keyed by session ID and turn ID, so a problematic interaction can be reconstructed quickly.&lt;/p&gt;

&lt;p&gt;You do not always need to store raw audio to debug effectively. In many cases, replaying timeline events is enough, and it is much safer from a privacy perspective.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment and scaling notes
&lt;/h2&gt;

&lt;p&gt;On deployment, I treat the voice path as a latency-sensitive service tier.&lt;/p&gt;

&lt;p&gt;Keep orchestration workers close to your media region where possible, and avoid unnecessary synchronous hops.&lt;/p&gt;

&lt;p&gt;FastAPI services running behind autoscaling can work well, but cold starts and noisy neighbours still matter for interactive voice.&lt;/p&gt;

&lt;p&gt;I usually separate concerns into at least two deployables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API/session control&lt;/li&gt;
&lt;li&gt;orchestration workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives you flexibility to scale orchestration independently when usage spikes.&lt;/p&gt;

&lt;p&gt;If you rely on Kubernetes, readiness checks should validate downstream dependency health rather than only process liveness.&lt;/p&gt;

&lt;p&gt;For a normal API, a process being alive may be enough to accept traffic. For real-time voice, a process that cannot reach STT, TTS, or the orchestration layer is not actually ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and privacy baseline
&lt;/h2&gt;

&lt;p&gt;Voice products can capture sensitive data by accident.&lt;/p&gt;

&lt;p&gt;Even if your first release is small, it is worth setting conservative boundaries early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;short retention for raw transcripts unless explicitly needed&lt;/li&gt;
&lt;li&gt;redaction for known sensitive fields in logs&lt;/li&gt;
&lt;li&gt;scoped credentials for room tokens and provider APIs&lt;/li&gt;
&lt;li&gt;clear user controls for muting&lt;/li&gt;
&lt;li&gt;clear consent flows&lt;/li&gt;
&lt;li&gt;deletion controls&lt;/li&gt;
&lt;li&gt;limited access to session logs&lt;/li&gt;
&lt;li&gt;separation between debugging metadata and sensitive content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These boundaries are much easier to create early than retrofit later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Building real-time voice AI applications is not only an LLM problem.&lt;/p&gt;

&lt;p&gt;It is a systems problem spanning networking, state management, retries, observability, security, and product interaction design.&lt;/p&gt;

&lt;p&gt;LiveKit and FastAPI make an effective foundation, but the quality comes from how you define boundaries and failure behaviour.&lt;/p&gt;

&lt;p&gt;For me, the winning pattern is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;predictable contracts&lt;/li&gt;
&lt;li&gt;explicit state&lt;/li&gt;
&lt;li&gt;tight latency feedback loops&lt;/li&gt;
&lt;li&gt;safe retries&lt;/li&gt;
&lt;li&gt;clear cancellation semantics&lt;/li&gt;
&lt;li&gt;observability from day one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is what keeps the experience feeling conversational while still operating like production software.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://joshuafields.uk/blog/building-real-time-voice-ai-applications-livekit-fastapi" rel="noopener noreferrer"&gt;Original Post&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
