<?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: Kingsley Onoh</title>
    <description>The latest articles on DEV Community by Kingsley Onoh (@kingsleyonoh).</description>
    <link>https://dev.to/kingsleyonoh</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F568563%2F01c09769-b072-47a3-9c7b-16fefc2c573e.png</url>
      <title>DEV Community: Kingsley Onoh</title>
      <link>https://dev.to/kingsleyonoh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kingsleyonoh"/>
    <language>en</language>
    <item>
      <title>Why I Froze Simulation Inputs Before the Solver Ran</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Thu, 28 May 2026 13:24:24 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/why-i-froze-simulation-inputs-before-the-solver-ran-592</link>
      <guid>https://dev.to/kingsleyonoh/why-i-froze-simulation-inputs-before-the-solver-ran-592</guid>
      <description>&lt;p&gt;A planner can approve the right transfer for the wrong reason.&lt;/p&gt;

&lt;p&gt;That was the failure mode I kept coming back to while building the Inventory Allocation Simulator. The solver could be mathematically correct, the recommendation could have positive net value, and the UI could show a clean explanation. But if the explanation reread today's warehouse and SKU tables after yesterday's simulation finished, the audit trail would be fiction.&lt;/p&gt;

&lt;p&gt;Inventory data changes constantly. A lane gets disabled. A SKU margin changes. Inbound units arrive. Demand history is corrected because a stockout period was recorded as zero sales. If a completed simulation depends on the current state of those tables, its story changes every time the business updates its planning data.&lt;/p&gt;

&lt;p&gt;I was wrong to treat that as a reporting problem at first. It was a data contract problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure hiding in a normal design
&lt;/h2&gt;

&lt;p&gt;The first design looked harmless: store a simulation run, run the worker, persist recommendations, and render the detail page by joining back to warehouses, SKUs, inventory, lanes, and policies. Most CRUD systems are written that way because joins are cheap and normalized tables keep data clean.&lt;/p&gt;

&lt;p&gt;That design breaks the moment a simulation becomes evidence.&lt;/p&gt;

&lt;p&gt;The recommendation is not just a row saying transfer 30 units. It needs to explain the constraint that bound the decision, the demand scenario that created the shortage, the service-level tail left unmet, and the tradeoffs accepted. In this project those fields live in &lt;code&gt;explanation&lt;/code&gt;: &lt;code&gt;binding_constraints&lt;/code&gt;, &lt;code&gt;scenario_sensitivity&lt;/code&gt;, &lt;code&gt;accepted_tradeoffs&lt;/code&gt;, &lt;code&gt;net_value&lt;/code&gt;, and solver diagnostics.&lt;/p&gt;

&lt;p&gt;If the explanation uses live tables, a planner can open the same completed run on Tuesday and see different supporting facts from Monday. That is worse than no explanation. It creates confidence in a record that no longer matches the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint I chose
&lt;/h2&gt;

&lt;p&gt;I made simulation creation the boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;create_simulation_run!&lt;/code&gt; authorizes the planner, validates the scenario count, captures every planning surface needed by the solver, and stores it inside &lt;code&gt;simulation_runs.input_snapshot&lt;/code&gt;. The worker consumes that snapshot. The detail page reads that snapshot. Demand scenarios are stored with the run. Completed runs do not ask the mutable catalog what the world looks like now.&lt;/p&gt;

&lt;p&gt;The core function is small, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight julia"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nf"&gt; capture_simulation_input_snapshot&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AbstractTenantAdminStore&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;TenantContext&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;policy_id&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt;
&lt;span class="x"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;NamedTuple&lt;/span&gt;
    &lt;span class="n"&gt;authorize!&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"run_cancel"&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"simulation"&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parsed_policy_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_uuid_value&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;policy_id&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_snapshot_policy&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parsed_policy_id&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="x"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="x"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;warehouses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="x"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_warehouse_response&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fetch_warehouses&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_snapshot_page&lt;/span&gt;&lt;span class="x"&gt;())],&lt;/span&gt;
        &lt;span class="n"&gt;skus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="x"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_sku_response&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fetch_skus&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_snapshot_page&lt;/span&gt;&lt;span class="x"&gt;())],&lt;/span&gt;
        &lt;span class="n"&gt;inventory_positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="x"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_inventory_response&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fetch_inventory_positions&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_snapshot_page&lt;/span&gt;&lt;span class="x"&gt;())],&lt;/span&gt;
        &lt;span class="n"&gt;demand_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="x"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_demand_response&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fetch_demand_history&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_snapshot_page&lt;/span&gt;&lt;span class="x"&gt;())],&lt;/span&gt;
        &lt;span class="n"&gt;transfer_lanes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="x"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_lane_response&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="x"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fetch_transfer_lanes&lt;/span&gt;&lt;span class="x"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="x"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_snapshot_page&lt;/span&gt;&lt;span class="x"&gt;())],&lt;/span&gt;
    &lt;span class="x"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snapshot is not elegant. It is intentionally blunt. The run carries the policy, warehouses, SKUs, inventory positions, demand history, and transfer lanes it used. &lt;code&gt;SNAPSHOT_MAX_ROWS&lt;/code&gt; is set to &lt;code&gt;1_000_000&lt;/code&gt;, which tells you the tradeoff plainly: this is a batch planning system, not a real-time transfer executor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;The snapshot decision also fixed a forecasting bug before it could become a solver bug.&lt;/p&gt;

&lt;p&gt;Stockout periods are dangerous because they make demand look low. In &lt;code&gt;clean_demand_history&lt;/code&gt;, the system stores both observed units and adjusted units. If demand was zero and lost sales were 82, the cleaned demand is 82. That value feeds the scenario generator. The stockout row also inflates uncertainty, because a period with unavailable inventory is less trustworthy than normal sales.&lt;/p&gt;

&lt;p&gt;The Batch 019 test mutates live inventory and demand after a run is created. Then it runs the worker and checks that the scenario baseline still comes from the frozen snapshot, not the updated live row. That was the test that made the architecture feel real. It did not just prove a function. It proved the system can remember what it believed when the recommendation was created.&lt;/p&gt;

&lt;p&gt;The alternative was versioning every table. That would give better diff history, but it would also make the MVP harder to operate. I chose the snapshot because the project needed completed-run honesty more than a general temporal database. A future version could move to event-sourced planning records. This one needed a concrete audit boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the solver fits
&lt;/h2&gt;

&lt;p&gt;The solver reads the frozen snapshot and stored scenarios, then builds a JuMP model over lanes, SKUs, inventory, service level, warehouse capacity, transfer cost, and safety stock. The model is allowed to fail. In fact, readable failure is part of the contract.&lt;/p&gt;

&lt;p&gt;A region rule can block every feasible transfer. A max transfer cost can make the plan infeasible. A timeout can return no acceptable incumbent. Those cases return diagnostics instead of pretending every scenario has a transfer.&lt;/p&gt;

&lt;p&gt;That mattered for the Journal topic because failure diagnostics have to describe the same world the solver saw. The &lt;code&gt;_constraint_report&lt;/code&gt; function can name &lt;code&gt;max_transfer_cost_cents&lt;/code&gt;, region blocking, sender safety stock, and receiver service-level constraints. If those facts came from live tables, the failure message could drift too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The deterministic solver fixture produces a 30-unit transfer from &lt;code&gt;WH-SURPLUS&lt;/code&gt; to &lt;code&gt;WH-NEED&lt;/code&gt;, with &lt;code&gt;lane_capacity&lt;/code&gt; and &lt;code&gt;receiver_service_level&lt;/code&gt; as binding constraints and a 30,900-cent net value. The large benchmark ran 50 warehouses, two thousand SKUs, and 100 scenarios in 17,928.4753 ms and generated two thousand recommendations.&lt;/p&gt;

&lt;p&gt;Those numbers matter less than the contract behind them. A completed run is not a report over current data. It is a preserved decision record. Once I made that boundary explicit, the rest of the system had somewhere honest to stand.&lt;/p&gt;

</description>
      <category>julia</category>
      <category>optimization</category>
      <category>simulation</category>
      <category>auditability</category>
    </item>
    <item>
      <title>Why I Moved Redis Acknowledgement Outside the Database Transaction</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Fri, 22 May 2026 10:22:55 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/why-i-moved-redis-acknowledgement-outside-the-database-transaction-1mdl</link>
      <guid>https://dev.to/kingsleyonoh/why-i-moved-redis-acknowledgement-outside-the-database-transaction-1mdl</guid>
      <description>&lt;p&gt;One good parcel event, one bad parcel event, one batch. That was enough to lose the good one.&lt;/p&gt;

&lt;p&gt;The consumer read both from Redis Streams. The first event was valid, so the app wrote a shipment event and a claim case, then acknowledged the Redis message. The second event had no tenant id, threw an exception, and rolled back the PostgreSQL transaction. Redis had already been told the first message was done.&lt;/p&gt;

&lt;p&gt;The database said nothing happened. Redis said the message was gone.&lt;/p&gt;

&lt;p&gt;That is the kind of bug that looks like an operations mystery later. A carrier says it sent the event. The stream no longer shows it as pending. The claim queue has no case. Everyone starts looking at logs, but the data has already contradicted itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong
&lt;/h2&gt;

&lt;p&gt;I treated Redis acknowledgement as part of processing. That was too early.&lt;/p&gt;

&lt;p&gt;The first version of &lt;code&gt;DeliveryEventConsumerJob.pollOnce()&lt;/code&gt; processed each stream record inside a &lt;code&gt;TransactionTemplate&lt;/code&gt;. It wrote through &lt;code&gt;ShipmentEventIngestionService.upsertFromEvent(...)&lt;/code&gt;, then acknowledged the message in the same loop. It felt clean because both actions sat inside the same method and both happened after the event handler returned.&lt;/p&gt;

&lt;p&gt;But Redis is not inside the PostgreSQL transaction. &lt;code&gt;XACK&lt;/code&gt; does not care whether the database later commits. Once Redis removes the message from the pending list, the recovery path changes. If the transaction rolls back after that ack, PostgreSQL loses the row and Redis loses the retry handle.&lt;/p&gt;

&lt;p&gt;That is not a duplicate problem. It is a vanished work problem.&lt;/p&gt;

&lt;p&gt;The codebase already had idempotency in the right place for duplicates. &lt;code&gt;ShipmentEventIngestionService&lt;/code&gt; builds a tenant-scoped dedup key from the event source and takes a PostgreSQL advisory transaction lock before inserting into &lt;code&gt;shipment_events&lt;/code&gt;. The table also has a &lt;code&gt;(tenant_id, dedup_key)&lt;/code&gt; uniqueness constraint. A replay can create at most one shipment event row and one claim case.&lt;/p&gt;

&lt;p&gt;I was wrong to optimize for avoiding replay. Replay was safe. Premature ack was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;The fix was small, but the boundary it moved mattered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;pollOnce&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;processedMessageIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;lockManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tryAcquireTransactionLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LOCK_NAME&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;streamClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureConsumerGroup&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;consumer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;consumerName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DeliveryGatewayTrackingEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;streamClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readPendingEvents&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;streamClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readNewEvents&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMillis&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;messageIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tenantsById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeliveryGatewayTrackingEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantsById&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;messageIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;messageIds&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processedMessageIds&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;processedMessageIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;streamClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;acknowledgeAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processedMessageIds&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;processedMessageIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transaction now returns the message ids only after the database work succeeds. Only then does the job call &lt;code&gt;acknowledgeAll&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That creates two possible failure states, and both are tolerable.&lt;/p&gt;

&lt;p&gt;If the database fails, no ack happens. Redis still has the pending messages. The next poll retries them.&lt;/p&gt;

&lt;p&gt;If the database commits and the ack fails, Redis may replay messages whose database rows already exist. That is fine. The dedup key, advisory lock, and unique index collapse the replay back to the existing shipment event. The system may do extra work, but it will not create a second claim.&lt;/p&gt;

&lt;p&gt;This is the tradeoff I should have chosen from the start: duplicate work over lost work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the batch cache came later
&lt;/h2&gt;

&lt;p&gt;The ack fix uncovered a different problem. The 50 events/sec acceptance test passed in isolation, then failed during full regression. The batch took 1,805 ms against a 1,000 ms target.&lt;/p&gt;

&lt;p&gt;At first glance, that looked like Redis overhead. It wasn't. The consumer was reading 50 messages and doing one batched ack, but &lt;code&gt;process(...)&lt;/code&gt; still resolved the same tenant and integration setting once per event. One tenant, 50 events, 50 database lookups before the hot path even reached shipment ingestion.&lt;/p&gt;

&lt;p&gt;The fix was not a global cache. A global tenant cache would create stale security behavior and make feature flags harder to trust. The right cache lived inside one poll batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tenantsById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeliveryGatewayTrackingEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantsById&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;messageIds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;process(...)&lt;/code&gt; now uses &lt;code&gt;computeIfAbsent&lt;/code&gt; to authorize each tenant once per poll. Every poll still checks whether the tenant has &lt;code&gt;delivery-gateway&lt;/code&gt; enabled. Nothing survives across polls. The latency win comes from removing repeated reads, not weakening the gate.&lt;/p&gt;

&lt;p&gt;What surprised me was how easy this bug was to miss. The system could pass duplicate-message tests, tenant-scope tests, and manual exception flow tests while still failing a real throughput target. It needed the acceptance test with 50 actual Redis messages and PostgreSQL writes to reveal the hidden cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database boundary still does the hard work
&lt;/h2&gt;

&lt;p&gt;The consumer job is only the outer shell. The correctness boundary lives in &lt;code&gt;ShipmentEventIngestionService.upsertFromEvent(...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That service takes the event, normalizes carrier, tracking number, status, timestamps, snapshots, and metadata. It builds an idempotency key under the tenant id. It locks that key with &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;. It upserts the shipment, inserts the event with &lt;code&gt;on conflict do nothing&lt;/code&gt;, updates the visible shipment status only when the event timestamp is not older, writes audit records, then asks &lt;code&gt;ClaimAutoCaseService&lt;/code&gt; whether the status deserves a case.&lt;/p&gt;

&lt;p&gt;Only three statuses create default cases: &lt;code&gt;failed_attempt&lt;/code&gt;, &lt;code&gt;returned&lt;/code&gt;, and &lt;code&gt;exception&lt;/code&gt;. A delivered scan does not open a claim. An unknown scan does not open a claim. The tracking timeline records context, but operations work begins only when the event status represents an operational exception.&lt;/p&gt;

&lt;p&gt;That separation matters because not every carrier signal should become work. A tracking system records facts. A claims system creates ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The regression test that mattered is blunt: publish a valid event and then an invalid event in the same Redis batch. Run &lt;code&gt;pollOnce()&lt;/code&gt;. Assert that no shipment event and no claim case exist, and that both Redis messages remain pending.&lt;/p&gt;

&lt;p&gt;That test now passes.&lt;/p&gt;

&lt;p&gt;The throughput proof also passes: 50 delivered events process in under 1 second locally, and because they are delivered events, they create zero claim cases. The duplicate stream test publishes two messages with the same source event and gets one shipment event, one claim case, and zero pending Redis messages after successful processing.&lt;/p&gt;

&lt;p&gt;The lesson here is not "ack after commit" as a slogan. The real rule is narrower: if one system owns retry visibility and another system owns business durability, the retry signal must not be cleared until the business fact is committed.&lt;/p&gt;

</description>
      <category>redisstreams</category>
      <category>springboot</category>
      <category>idempotency</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The Document Number Is Reserved Before the PDF Exists</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Mon, 11 May 2026 08:21:48 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/the-document-number-is-reserved-before-the-pdf-exists-5fdp</link>
      <guid>https://dev.to/kingsleyonoh/the-document-number-is-reserved-before-the-pdf-exists-5fdp</guid>
      <description>&lt;p&gt;The hard part of document numbering is not incrementing an integer.&lt;/p&gt;

&lt;p&gt;It is deciding what happens when the integer is reserved, rendering starts, and the render fails.&lt;/p&gt;

&lt;p&gt;PostgreSQL sequences are built for speed. They are not built for legal numbering. A sequence advances even if the surrounding transaction rolls back. For most applications that is fine. For invoices and board records, a gap is not invisible. If number 41 exists and number 43 exists, someone will ask what happened to 42.&lt;/p&gt;

&lt;p&gt;I needed a numbering system that could do three things at once: serialize per entity, type, and year; let different sequences run in parallel; and preserve a reason when a number is skipped.&lt;/p&gt;

&lt;p&gt;That became the two-phase allocator in &lt;code&gt;src/documents/numbering.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Phase one reserves the next number. The allocator locks the tuple &lt;code&gt;(entity_id, document_type, year)&lt;/code&gt; with &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;, checks for the oldest unclaimed gap, and only then advances the high-water mark. It writes the reservation to &lt;code&gt;pending_allocations&lt;/code&gt; with a TTL.&lt;/p&gt;

&lt;p&gt;Phase two either finalizes the reservation against a document row or releases it into &lt;code&gt;sequence_gaps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The core reservation shape is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;acquireAllocatorLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;reservedNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tryReclaimOldestGap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservedNumber&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;reservedNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;computeNextHighWater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`INSERT INTO pending_allocations
     (id, entity_id, document_type, year, reserved_number,
      reserved_by_api_key_id, reserved_at, expires_at, metadata)
   VALUES ($1, $2, $3, $4, $5, $6, now(),
           now() + ($7::text)::interval,
           $8::jsonb)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;allocationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reservedNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKeyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advisory lock boundary matters. FZE invoices for 2026 serialize against other FZE invoices for 2026. LLC board resolutions do not wait on them. A document type has its own counter space because letterhead #1 and compliance letter #1 can both be real first documents in their own family.&lt;/p&gt;

&lt;p&gt;The part I got wrong early was the SQL shape for reclaiming a gap.&lt;/p&gt;

&lt;p&gt;The first version used an update with a subquery and a limit against the same table. PostgreSQL flattened the plan in a way that ignored the limit and updated every eligible row. That is the kind of bug that looks impossible until you inspect the affected rows. The fixed version uses a CTE target so the candidate set is materialized before the update runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`WITH target AS (
     SELECT id FROM sequence_gaps
      WHERE entity_id = $2 AND document_type = $3 AND year = $4
        AND reclaimed_at IS NULL
      ORDER BY reaped_at ASC
      LIMIT 1
      FOR UPDATE SKIP LOCKED
   )
   UPDATE sequence_gaps
      SET reclaim_allocation_id = $1, reclaimed_at = now()
    WHERE id IN (SELECT id FROM target)
    RETURNING id, gap_number`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&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 one CTE is the difference between reclaiming one documented gap and mutating the whole backlog.&lt;/p&gt;

&lt;p&gt;The reaper is the other half of the design. A reservation can expire before it is attached to a document. Maybe Puppeteer failed. Maybe the sidecar was down. Maybe storage rejected the upload. The service cannot just delete the reservation and pretend the number never happened. It moves the number to &lt;code&gt;sequence_gaps&lt;/code&gt; with &lt;code&gt;reason = 'reaper_swept'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are also explicit reasons: abandoned reservation, admin documented gap, manual void. That vocabulary is important because auditors do not need a philosophical explanation. They need a row that says why the number did not produce a document.&lt;/p&gt;

&lt;p&gt;What surprised me was how much of the design was about not being too clever. I could have hidden this behind one &lt;code&gt;nextDocumentNumber()&lt;/code&gt; helper and let failures be retried. That would make the happy path smaller and the audit story weaker. The split between pending allocation and sequence gap is more verbose, but the data tells the truth.&lt;/p&gt;

&lt;p&gt;The same pattern shows up in the tests. The reaper-race gate is not a decorative concurrency test. It exists because the allocator is only correct under pressure: concurrent reservations, expired rows, reclaimed gaps, and failed finalization must all preserve one property. No two documents get the same number in the same sequence, and no missing number lacks a reason.&lt;/p&gt;

&lt;p&gt;The document number is reserved before the PDF exists because rendering is fallible. The audit trail has to survive that fallibility.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>concurrency</category>
      <category>numbering</category>
      <category>audit</category>
    </item>
    <item>
      <title>The Audit Trail Is a Data Structure, Not a Log Message</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Mon, 11 May 2026 08:21:05 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/the-audit-trail-is-a-data-structure-not-a-log-message-42mj</link>
      <guid>https://dev.to/kingsleyonoh/the-audit-trail-is-a-data-structure-not-a-log-message-42mj</guid>
      <description>&lt;p&gt;Logs can explain what a service thought happened.&lt;/p&gt;

&lt;p&gt;They do not prove what happened.&lt;/p&gt;

&lt;p&gt;Klevar Docs needed an audit trail for rendered documents, invoice events, credit note applications, signatures, voids, and attachments. The usual answer is an events table. Insert a row whenever something happens. Add timestamps. Keep it forever. That is useful, but it is still just a table unless the table can detect tampering.&lt;/p&gt;

&lt;p&gt;The hash chain is the difference.&lt;/p&gt;

&lt;p&gt;Each entity gets its own ordered chain. A row stores the event payload, a SHA-256 hash of the canonical payload, the previous row hash, the chain index, and a link hash that binds those values together. If someone changes a payload, deletes a prior row, swaps entity rows, or reorders entries, verification fails.&lt;/p&gt;

&lt;p&gt;The append path is transactional with the document change. That detail matters more than the hashing. If the document row commits and the chain row rolls back, the proof is incomplete. If the chain row commits and the document row rolls back, the proof references a thing that does not exist. &lt;code&gt;insertChainEntry()&lt;/code&gt; is designed to run inside the caller's transaction.&lt;/p&gt;

&lt;p&gt;The core logic is direct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allocRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&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="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SELECT fn_allocate_chain_index(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::uuid)::text AS idx`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chainIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allocRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chainIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priorRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&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="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SELECT payload_hash FROM document_hash_chain
         WHERE entity_id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::uuid
           AND chain_index = &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;chainIndex&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;::bigint`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;previousHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;priorRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload_hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chainLinkHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeChainLinkHash&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;content_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;previous_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;chain_index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chainIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two design choices hidden in that snippet.&lt;/p&gt;

&lt;p&gt;The index comes from &lt;code&gt;fn_allocate_chain_index(entity_id)&lt;/code&gt;, not a PostgreSQL sequence. The same rollback problem that makes sequences wrong for legal document numbers also applies to chain indices. A verifier expects the chain to be contiguous. If index 19 is missing because a transaction rolled back after a sequence increment, the verifier cannot know whether that is harmless or tampering.&lt;/p&gt;

&lt;p&gt;The link hash includes &lt;code&gt;entity_id&lt;/code&gt;. That prevents a row from one entity being copied into another entity's chain without detection. Klevar has one group boundary, but the legal proof is per entity. FZE, LLC, and Ltd cannot share a chain just because the service is single-tenant.&lt;/p&gt;

&lt;p&gt;The verifier is a walker, not a database query. It receives rows sorted by &lt;code&gt;chain_index&lt;/code&gt;, checks ordering, checks the genesis row, checks each &lt;code&gt;previous_hash&lt;/code&gt;, recomputes each link hash, and returns the first mismatch. It also reports intentionally broken indices. That last category is important because some retention or force-purge action may be documented rather than hidden. A broken chain can be honest if the break is recorded and visible.&lt;/p&gt;

&lt;p&gt;What surprised me was the dependency on canonical JSON. Hashing JavaScript objects directly is a trap because key order and serialization details can drift. The service pins &lt;code&gt;canonicalize@2.0.0&lt;/code&gt; and runs an RFC 8785 boot assertion before Fastify binds a port. If canonicalization changes, the server refuses to start. That is not paranoia. It is the cost of using hashes as legal proof.&lt;/p&gt;

&lt;p&gt;The hash chain also changed how I think about events. &lt;code&gt;events_emitted&lt;/code&gt; is the integration outbox for Hub and Webhook Engine fanout. It is operational. &lt;code&gt;document_hash_chain&lt;/code&gt; is proof. Those two surfaces overlap, but they are not the same thing. A notification can be retried, delayed, or dropped without changing the legal document. A chain append cannot be treated that way.&lt;/p&gt;

&lt;p&gt;The biggest tradeoff is operational weight. A chain gives you another invariant to maintain, another verifier to run, another repair story to document, and another failure mode to alert on. The alternative is worse: a document archive that can produce files but cannot prove nobody altered the history.&lt;/p&gt;

&lt;p&gt;For this system, the archive without the chain would be storage. The chain turns it into evidence.&lt;/p&gt;

</description>
      <category>hashchain</category>
      <category>audit</category>
      <category>canonicaljson</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Why a Rendered Invoice Can Still Fail Send</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Mon, 11 May 2026 08:20:52 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/why-a-rendered-invoice-can-still-fail-send-32ka</link>
      <guid>https://dev.to/kingsleyonoh/why-a-rendered-invoice-can-still-fail-send-32ka</guid>
      <description>&lt;p&gt;An invoice PDF can exist and still not be an invoice package.&lt;/p&gt;

&lt;p&gt;That sentence shaped the send path. The easy implementation would render the invoice, store the PDF, try to build the XML, and log a warning if the compliance layer failed. The client still gets a document. The API still returns success. The business can "fix it later."&lt;/p&gt;

&lt;p&gt;That is exactly the failure I did not want.&lt;/p&gt;

&lt;p&gt;Klevar Docs treats e-invoicing as part of finalization, not as decoration after rendering. For an invoice that routes to Factur-X, the send path has to build CII XML, validate it, embed it into the PDF, convert the container to PDF/A-3b, validate that with veraPDF, upload the XML, and replace the PDF with the conformant output. If any hard requirement fails, the send fails.&lt;/p&gt;

&lt;p&gt;The orchestrator is intentionally thin. &lt;code&gt;buildComplianceArtifacts()&lt;/code&gt; does profile resolution and dispatches to a branch. The Factur-X branch owns the hard path. The XRechnung branch owns the public-sector German XML path.&lt;/p&gt;

&lt;p&gt;The Factur-X branch is where the philosophy is visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;validationRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markDocumentFacturXFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FacturXValidationRejectError&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;schematron_failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validationRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;validationRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="na"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;factur_x_validation_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallbackResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;embedAndValidateWithFallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;plainPdfBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fallbackContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fallbackDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fallbackResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conformant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfARequiredRejectError&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf_a_non_conformant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stage_failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fallbackResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage_failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two details in that snippet that matter.&lt;/p&gt;

&lt;p&gt;First, validation failure is a typed 422, not an internal server error. The invoice shape is wrong. The operator needs to fix the invoice, not restart the service. Missing BT fields, sidecar 4xx failures, and schematron failures all become the same class of business-visible rejection.&lt;/p&gt;

&lt;p&gt;Second, the document row is marked as failed before throwing. That was a later correction. Without it, a failed send could leave the row looking cleaner than reality because the broader transaction never committed. The code now best-effort updates &lt;code&gt;documents.factur_x_validation_status = 'fail'&lt;/code&gt; so a polling operator sees the truth after a rejection.&lt;/p&gt;

&lt;p&gt;What surprised me was how much logic had to live before the sidecar call. I expected mustangproject to be the hard part. The harder part was building the DTO without lying. Seller name, seller VAT ID, registration ID, address, client identity, tax category, reverse charge handling, unit code, document type, original invoice reference, payment terms, issue date, due date. Every one of those fields has a business rule.&lt;/p&gt;

&lt;p&gt;The builder is pure TypeScript for that reason. It reads frozen invoice, client, and entity snapshot data and returns a transport DTO for the Java sidecar. It does not query the database. It does not default from live entity state. It does not mutate the invoice.&lt;/p&gt;

&lt;p&gt;That purity paid off when credit notes entered the path. A credit note is not just a negative invoice. It carries a different document type code and can need a preceding invoice reference. The builder got an explicit discriminator for &lt;code&gt;invoice&lt;/code&gt; versus &lt;code&gt;credit_note&lt;/code&gt;, and the sidecar maps that into the XML. The alternative was to infer from amounts, which would have been clever and wrong.&lt;/p&gt;

&lt;p&gt;XRechnung is intentionally different. German public-sector invoices route to standalone XML. The human-readable PDF remains a companion, not the legal container. That means the branch skips PDF/A-3b embedding work and persists XML as the authoritative artifact. Today the validator status can be &lt;code&gt;pass&lt;/code&gt;, &lt;code&gt;fail&lt;/code&gt;, or &lt;code&gt;skipped&lt;/code&gt; because the KoSIT validation lane had an acknowledged gap. I kept that explicit instead of pretending both branches had identical maturity.&lt;/p&gt;

&lt;p&gt;The system now has an uncomfortable but correct behavior: it can render a beautiful PDF, then refuse to send it.&lt;/p&gt;

&lt;p&gt;That is the point. A valid-looking artifact is not enough. The service needs to know whether the document is acceptable for the legal path it is taking. For a plain letterhead, PDF bytes may be enough. For an invoice that routes to Factur-X, the XML and PDF/A container are part of the document. If they fail, the document failed.&lt;/p&gt;

&lt;p&gt;I would rather make the operator fix a rejection than let a client receive a file that only looks complete.&lt;/p&gt;

</description>
      <category>facturx</category>
      <category>xrechnung</category>
      <category>pdfa</category>
      <category>invoices</category>
    </item>
    <item>
      <title>The PDF Looked Correct Because the Template Was Wrong</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Mon, 11 May 2026 08:20:09 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/the-pdf-looked-correct-because-the-template-was-wrong-3hia</link>
      <guid>https://dev.to/kingsleyonoh/the-pdf-looked-correct-because-the-template-was-wrong-3hia</guid>
      <description>&lt;p&gt;The first FZE letterhead looked fine.&lt;/p&gt;

&lt;p&gt;That was the problem.&lt;/p&gt;

&lt;p&gt;The rendered PDF had the right legal name, the right registration label, the right address, the right contact line, and the right visual structure. It passed the visual check because every value in the template matched the entity I was testing with. Then I looked at what would happen if the same bundle rendered an LLC document.&lt;/p&gt;

&lt;p&gt;It would still say FZE.&lt;/p&gt;

&lt;p&gt;That failure is more dangerous than a crash. A crash stops the send path. A wrong legal identity in a PDF can leave the system looking healthy while the document is unusable. The template authoring lane had hardcoded FZE identity strings because the snapshot did not expose the fields the template needed. The implementation had chosen the fastest path to a green render instead of stopping at the missing data contract.&lt;/p&gt;

&lt;p&gt;I was wrong to treat the template as the place where legal identity could be finished. The template is presentation. The entity row is state. The document snapshot is the contract between them.&lt;/p&gt;

&lt;p&gt;The fix started by widening the entity surface. &lt;code&gt;captureEntitySnapshot()&lt;/code&gt; now freezes address, registration, contact, legal names, country code, VAT identifier, officer data, brand, banking config, retention posture, and document policy into the document row. It also redacts fields that should not land on PDFs, like private tax identifiers and operational banking rollout notes.&lt;/p&gt;

&lt;p&gt;The load-bearing part is that the snapshot always returns stable shapes. Handlebars runs in strict mode. If a template asks for &lt;code&gt;entity.address.single_line&lt;/code&gt;, the &lt;code&gt;address&lt;/code&gt; path must exist even when the row is old. Empty object is better than &lt;code&gt;undefined&lt;/code&gt; because an old document can still render through the same code path.&lt;/p&gt;

&lt;p&gt;The function is blunt about that boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureEntitySnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntityRowForSnapshot&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;EntitySnapshot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;entity_snapshot_invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id missing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cloneJsonLike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;banking_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;redactBankingConfigForDocuments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;banking_config&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cloneJsonObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;redactRegistrationForDocuments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cloneJsonObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contact&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;legal_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toNullableString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legal_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toNullableString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;vat_identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toNullableString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vat_identifier&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;officers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cloneJsonObjectArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pickOfficers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&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 code does not look dramatic. It is the reason a document can be re-rendered later without asking the live &lt;code&gt;entities&lt;/code&gt; row what the company looks like today.&lt;/p&gt;

&lt;p&gt;The surprise was CSS. The HTML literals were obvious once I started searching. The comments in the stylesheet were not. The composer injects CSS directly into a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block and hashes the full rendered HTML. A comment like &lt;code&gt;Klevar FZE primary&lt;/code&gt; is still rendered output. It becomes part of the document hash. It can leak into the PDF byte stream. It can fail an entity-neutral regression even if the visible page looks correct.&lt;/p&gt;

&lt;p&gt;That changed the template rule: anything inside the bundle is document output. Body HTML, shared partials, stylesheet comments, labels, helper inputs. None of it gets to carry entity identity unless it comes from the snapshot or the body payload.&lt;/p&gt;

&lt;p&gt;The renderer also became stricter in a different direction. &lt;code&gt;composeHtml()&lt;/code&gt; uses a private Handlebars instance for each render, not the global singleton. Helpers like &lt;code&gt;formatCurrency&lt;/code&gt;, &lt;code&gt;formatDate&lt;/code&gt;, &lt;code&gt;markdown&lt;/code&gt;, &lt;code&gt;eq&lt;/code&gt;, and &lt;code&gt;officerRoleLabel&lt;/code&gt; live inside that private instance. That prevents a test, module, or future template family from registering a helper globally and changing another document type by accident.&lt;/p&gt;

&lt;p&gt;The snapshot fix did not end at source code. The regression had to prove the failure could not return. The gate renders every authored bundle against multiple seeded entities and searches for identity strings from the wrong entity. If an LLC render contains an FZE registration value, the test fails. If a stylesheet comment leaks a company name, the test fails. If someone adds a new bundle and hardcodes a legal name because it is faster, the gate catches it.&lt;/p&gt;

&lt;p&gt;The deeper lesson is that PDF generation is not the domain. Legal attribution is the domain.&lt;/p&gt;

&lt;p&gt;A renderer that accepts a body and returns bytes is easy to build. A renderer that can explain where every legal identity field came from is the system. Once I saw that, the architecture became clearer: templates do not own identity, live rows do not own old documents, and snapshots do not carry private operational fields just because the database has them.&lt;/p&gt;

&lt;p&gt;That distinction now runs through the rest of Klevar Docs. Factur-X builder data comes from the snapshot. Board resolution officer data can default from the snapshot. Payment details freeze at issue time. Hash-chain verification depends on content hashes that include the rendered output. If a lower layer cheats, every upper layer can look correct while proving the wrong thing.&lt;/p&gt;

&lt;p&gt;The PDF looking correct was the warning. The system only became correct when the source of correctness moved out of the template.&lt;/p&gt;

</description>
      <category>documentrendering</category>
      <category>snapshots</category>
      <category>templates</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Reachable Is Not the Same as Correct</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Mon, 11 May 2026 08:19:56 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/reachable-is-not-the-same-as-correct-55pc</link>
      <guid>https://dev.to/kingsleyonoh/reachable-is-not-the-same-as-correct-55pc</guid>
      <description>&lt;p&gt;The CLI could create a credit note.&lt;/p&gt;

&lt;p&gt;That was the bad news.&lt;/p&gt;

&lt;p&gt;The umbrella &lt;code&gt;documents compose&lt;/code&gt; command was built to make all document types reachable through one stable surface. Pass a &lt;code&gt;type&lt;/code&gt;, an entity, and a JSON body. The server looks up the per-type Zod schema, validates the body, renders the document, and returns the same envelope shape every time.&lt;/p&gt;

&lt;p&gt;For generic documents, that is exactly right. A letterhead, proposal, quote, statement, receipt, minutes document, reference letter, or compliance letter can be a schema-gated render through the generic pipeline.&lt;/p&gt;

&lt;p&gt;Credit notes were different. So were invoices, pro-forma invoices, and board resolutions.&lt;/p&gt;

&lt;p&gt;Those document types do not just render. They create specialized rows, allocate numbers, enforce state machines, inherit fields from source invoices, attach payment behavior, and feed later compliance paths. When &lt;code&gt;documents compose --type credit_note&lt;/code&gt; went through the generic renderer, it produced a document row but bypassed &lt;code&gt;createCreditNote()&lt;/code&gt;. That meant the credit note skipped the inheritance logic that copies terms from the original invoice.&lt;/p&gt;

&lt;p&gt;The symptom appeared later as a Factur-X validation problem. The XML layer complained about missing payment terms. The real bug was earlier: the API made a specialized document reachable through the wrong path.&lt;/p&gt;

&lt;p&gt;I had treated coverage as correctness. It was not.&lt;/p&gt;

&lt;p&gt;The fix was not to delete the umbrella. The umbrella is still the right operator surface. The fix was to split the server path into two routes inside the same endpoint: specialized dispatch first, generic render second.&lt;/p&gt;

&lt;p&gt;The registry is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COMPOSE_SPECIALIZED_DISPATCH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TemplateType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ComposeDispatcher&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoiceDispatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pro_forma_invoice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;proFormaDispatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credit_note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;creditNoteDispatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;board_resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boardResolutionDispatcher&lt;/span&gt;&lt;span class="p"&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 tiny map carries a big rule. If a type has specialized business logic, compose must call the specialized service. If it does not, compose falls through to &lt;code&gt;renderDocument()&lt;/code&gt;. Exactly one path fires.&lt;/p&gt;

&lt;p&gt;The dispatcher contract is intentionally boring. It maps the umbrella body into the specialized service input, calls the service, and wraps the returned row in the same envelope shape as a rendered document. It does not recompute totals. It does not duplicate inheritance. It does not allocate its own number. If the specialized service owns the rule, the dispatcher is only an adapter.&lt;/p&gt;

&lt;p&gt;Here is the credit note path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createCreditNote&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKeyId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;wrapRowAsOutput&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;credit_note&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;documentNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;document_number&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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 looks like extra ceremony until you compare it to the failure. The old path returned a rendered artifact while skipping the domain rule. The new path returns a draft row because specialized documents often require a later send or execute step to produce their final artifact. The response shape stays stable, but the lifecycle is honest.&lt;/p&gt;

&lt;p&gt;What surprised me was that the bug survived because both layers were internally correct. The CLI sent a valid body. The compose endpoint validated it. The generic renderer produced a document. The Factur-X validator was right to reject the later XML. No single module was obviously broken in isolation.&lt;/p&gt;

&lt;p&gt;The boundary was wrong.&lt;/p&gt;

&lt;p&gt;That forced a different kind of test. Unit tests on the dispatcher are not enough. The integration test has to assert the side effect that only the specialized service can create: invoice numbering, credit-note inheritance, board-resolution numbering, generic letterhead finalization. The test is not "does compose return 201?" It is "did the right domain path fire?"&lt;/p&gt;

&lt;p&gt;This is one of the more useful patterns in the project because it applies outside documents. An umbrella command is good when operators need one muscle memory. It becomes dangerous when the umbrella erases domain-specific behavior. Reachability is a UX property. Correctness is a domain property.&lt;/p&gt;

&lt;p&gt;The compose endpoint now carries both.&lt;/p&gt;

</description>
      <category>apidesign</category>
      <category>cli</category>
      <category>dispatch</category>
      <category>documenttypes</category>
    </item>
    <item>
      <title>Confidence Is Not Ownership</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Wed, 06 May 2026 16:00:28 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/confidence-is-not-ownership-2d80</link>
      <guid>https://dev.to/kingsleyonoh/confidence-is-not-ownership-2d80</guid>
      <description>&lt;p&gt;What should a finance queue do when two credible records point at the same case?&lt;/p&gt;

&lt;p&gt;An invoice discrepancy and a contract breach can describe the same dispute. They can also describe two different disputes with the same counterparty, the same currency, and nearly the same amount. That is the trap in a finance operations queue. The data looks related before anyone has proved ownership.&lt;/p&gt;

&lt;p&gt;The Workbench ingests exceptions from invoice reconciliation, transaction reconciliation, contract lifecycle events, webhook dead letters, manual operator entry, and signed Hub fanout. Every source carries its own identifiers. Some are reliable. Some are only reliable inside the upstream tool that produced them.&lt;/p&gt;

&lt;p&gt;I had to decide what the system should do when a new exception looks like it belongs to an existing dispute.&lt;/p&gt;

&lt;p&gt;The tempting version is simple: compute a score, pick the highest dispute, attach the exception. That makes demos feel clean. Exceptions flow in, disputes become richer, and the queue stays small.&lt;/p&gt;

&lt;p&gt;It is also how a finance system quietly corrupts its own audit trail.&lt;/p&gt;

&lt;p&gt;Once an exception is attached to a dispute, every later action inherits that fact. SLA timers, resolution playbooks, Notification Hub events, audit PDF exports, and operator comments all treat the relationship as true. If the relationship was only probable, the system has converted probability into evidence.&lt;/p&gt;

&lt;p&gt;That conversion is the real design problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scoring Shape
&lt;/h2&gt;

&lt;p&gt;The correlator in &lt;code&gt;src/drw/domain/correlator.clj&lt;/code&gt; uses seven signals. Source reference and entity id each carry 0.15. Counterparty carries 0.25. Currency carries 0.10. Amount carries 0.15. Date carries 0.10. Category carries 0.10.&lt;/p&gt;

&lt;p&gt;Those weights are not magic in the sense of being secret. They are visible because this is a spec project. But the structure matters more than the numbers.&lt;/p&gt;

&lt;p&gt;Counterparty is the gate. A candidate dispute is not eligible unless it belongs to the same tenant, is not terminal, has the same counterparty, and falls within the correlation window. Only then does scoring begin.&lt;/p&gt;

&lt;p&gt;That means the correlator is not a general similarity search. It is a tenant-scoped dispute ownership test.&lt;/p&gt;

&lt;p&gt;The amount signal has a 10 percent tolerance and only scores when the currency also matches. The date signal checks whether the exception was observed within 72 hours of the dispute creation time. The source reference and entity id signals compare against exceptions already attached to the candidate dispute, not just fields on the dispute itself.&lt;/p&gt;

&lt;p&gt;That last part matters. A dispute becomes easier to recognize as it accumulates evidence. The first invoice mismatch may create the dispute. A later webhook dead letter with the same upstream reference can now match the attached evidence, even if the dispute record itself does not carry that reference.&lt;/p&gt;

&lt;p&gt;The core function is plain Clojure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight clojure"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;defn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;score-candidates&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;tenant-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disputes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attached&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;score-candidates&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disputes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attached&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}))&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;tenant-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disputes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attached&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;merge&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;default-config&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get-in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;:thresholds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:review&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disputes&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;map-indexed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;candidate-eligible?&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dispute&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;map&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;&lt;span class="w"&gt;
                 &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;assoc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;score-candidate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dispute&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attached&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
                        &lt;/span&gt;&lt;span class="no"&gt;:sort-index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;:score&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;%&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort-by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;juxt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;comp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:sort-index&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mapv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dissoc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:sort-index&lt;/span&gt;&lt;span class="p"&gt;))))))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two details in that function I care about.&lt;/p&gt;

&lt;p&gt;First, eligibility happens before scoring. A cross-tenant dispute receives no score. A terminal dispute receives no score. A different counterparty receives no score. The function does not let a high amount or date match compensate for a broken boundary.&lt;/p&gt;

&lt;p&gt;Second, ties preserve input order through &lt;code&gt;:sort-index&lt;/code&gt;. That is not glamorous. It prevents unstable review queues where two equal candidates swap positions between renders and make operators think the system changed its mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Was Wrong About
&lt;/h2&gt;

&lt;p&gt;I initially treated the correlation score as the hard part.&lt;/p&gt;

&lt;p&gt;It was not. The harder question was what the system does with the score.&lt;/p&gt;

&lt;p&gt;There are three possible outcomes in &lt;code&gt;src/drw/domain/exceptions.clj&lt;/code&gt;. If no candidate passes review, the exception creates a new dispute and attaches immediately. If the best candidate hits the auto-merge band and auto-merge is explicitly enabled, the exception attaches and records an auto-merged correlation. Otherwise, the system creates pending correlation records and emits a &lt;code&gt;dispute.correlation_pending&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;That middle branch is intentionally hard to reach. The &lt;code&gt;.env.example&lt;/code&gt; values set &lt;code&gt;AUTO_MERGE_THRESHOLD=0.92&lt;/code&gt; and &lt;code&gt;REVIEW_THRESHOLD=0.70&lt;/code&gt;, while the source correlator defaults are lower for unit-level behavior. The runtime config is stricter because this is finance operations. False attachment costs more than a larger review queue.&lt;/p&gt;

&lt;p&gt;What surprised me is that pending correlation became a domain object, not a UI convenience.&lt;/p&gt;

&lt;p&gt;The queue needed an id, a tenant id, an exception id, a target dispute id, a score, a rationale, a status, a decided-by user, and decision timestamps. That is a lot of structure for something that could have been a modal row.&lt;/p&gt;

&lt;p&gt;But the moment an operator accepts or rejects a candidate, that decision becomes part of the case history. A rejected match is useful evidence. It says someone looked at the overlap and decided the exception did not belong there. If the same upstream source sends a related item later, the prior rejection explains why the system did not combine the cases earlier.&lt;/p&gt;

&lt;p&gt;That is why correlation records live next to exceptions and disputes instead of inside a transient UI response.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Mode Hidden In Good Matches
&lt;/h2&gt;

&lt;p&gt;The most dangerous false match is not a ridiculous one.&lt;/p&gt;

&lt;p&gt;It is the match that looks reasonable.&lt;/p&gt;

&lt;p&gt;Same counterparty. Same currency. Amount within 10 percent. Observed inside three days. Category is billing. If those signals point to the wrong open dispute, the system does not look broken. It looks efficient.&lt;/p&gt;

&lt;p&gt;The damage appears later. A Workflow Engine playbook starts against the wrong case. A Notification Hub event tells an operator that the dispute is ready for resolution. The audit PDF now contains an exception that belongs somewhere else. Nobody sees the root mistake because every downstream artifact is internally consistent.&lt;/p&gt;

&lt;p&gt;That is the kind of bug that worries me more than a 500 response.&lt;/p&gt;

&lt;p&gt;A 500 stops the flow. A wrong attachment keeps moving.&lt;/p&gt;

&lt;p&gt;The design answer was to make confidence create a decision, not mutate the dispute. A review-band candidate becomes work for an operator. The UI exposes accept and reject actions. The API carries the same boundary. The audit log records correlation creation and later decisions.&lt;/p&gt;

&lt;p&gt;The Workbench still supports auto-merge, but it is a policy choice. It has to be enabled. The score has to clear the higher band. The code does not pretend the existence of a scoring function means the business has accepted the risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tenant Scope Belongs Inside The Algorithm
&lt;/h2&gt;

&lt;p&gt;Tenant isolation is usually discussed at the HTTP layer. API key comes in, tenant id gets attached to the request, handlers filter queries.&lt;/p&gt;

&lt;p&gt;That is necessary, but it is not enough here.&lt;/p&gt;

&lt;p&gt;Correlation is a cross-entity operation by nature. It compares a new exception against many existing disputes and attached exceptions. If the algorithm accepts a list that accidentally contains another tenant's disputes, the HTTP layer is already too far away to save it.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;score-candidate&lt;/code&gt; checks tenant equality itself. &lt;code&gt;score-candidates&lt;/code&gt; filters candidates through &lt;code&gt;candidate-eligible?&lt;/code&gt;, which repeats tenant, status, counterparty, and time-window checks before scoring.&lt;/p&gt;

&lt;p&gt;This is defensive duplication with a purpose. The route should pass tenant-scoped collections. The domain should still reject anything outside the tenant boundary. In a single-tenant fixture, this looks redundant. In a two-tenant test, it is the difference between "the route behaved" and "the invariant held."&lt;/p&gt;

&lt;p&gt;The same philosophy appears in reports. The audit PDF renderer captures a tenant snapshot, renders with strict token lookup, and the setup check renders two tenants to make sure one tenant's identity literals never appear in the other tenant's output.&lt;/p&gt;

&lt;p&gt;The theme is the same: do not trust a boundary because a previous layer probably handled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;The finished local build passed 160 tests with 869 assertions. The full-flow E2E drives invoice adapter polling into exception creation, assignment, investigation, Workflow Engine resolution polling, Notification Hub event capture, and audit PDF generation.&lt;/p&gt;

&lt;p&gt;The number I care about most is smaller: the dashboard guard. It caught a practical operations failure. The first dashboard shape rendered every dispute link in the tenant fixture. The fix capped the overview at 50 open disputes and kept totals intact.&lt;/p&gt;

&lt;p&gt;That is the Workbench in miniature. Preserve the facts. Limit the surface. Make the operator decide when the machine only has a probability.&lt;/p&gt;

&lt;p&gt;Confidence is useful. Ownership is a human or policy decision.&lt;/p&gt;

</description>
      <category>clojure</category>
      <category>correlation</category>
      <category>financeoperations</category>
      <category>tenantisolation</category>
    </item>
    <item>
      <title>Why I Made WebSocket Delivery the Disposable Part of the Tracking System</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Wed, 06 May 2026 14:53:35 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/why-i-made-websocket-delivery-the-disposable-part-of-the-tracking-system-1fej</link>
      <guid>https://dev.to/kingsleyonoh/why-i-made-websocket-delivery-the-disposable-part-of-the-tracking-system-1fej</guid>
      <description>&lt;p&gt;The uncomfortable failure is not a carrier outage. That one is loud. The polling loop logs it, retries it, and moves on.&lt;/p&gt;

&lt;p&gt;The failure I had to design around is quieter: the gateway receives a real carrier update, writes it to the database, then Redis is down at the exact moment the WebSocket fanout should happen. The client misses the live push. The shipment state is still correct. The event timeline is still correct. But the thing the user was watching in real time never moves.&lt;/p&gt;

&lt;p&gt;That sounds like a broken real-time system until you decide which part is allowed to be temporary.&lt;/p&gt;

&lt;p&gt;I made the database the truth and the WebSocket stream the delivery layer. That one decision shaped the rest of the gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real boundary
&lt;/h2&gt;

&lt;p&gt;DHL, DPD, and GLS do not send one clean stream of facts. The adapters have to handle different request shapes, different status codes, and different timestamp rules. DHL uses &lt;code&gt;DHL-API-Key&lt;/code&gt; and returns shipment events under &lt;code&gt;shipments[0].events&lt;/code&gt;. DPD can return HTML for an error response, so &lt;code&gt;DpdAdapter&lt;/code&gt; checks the content type before it ever tries to parse JSON. GLS sends a date like &lt;code&gt;06.05.2026&lt;/code&gt; and a local time string, so &lt;code&gt;GlsAdapter&lt;/code&gt; has to parse CET into UTC.&lt;/p&gt;

&lt;p&gt;That is all adapter work. Once an event crosses into the core, it has one shape: carrier, tracking number, carrier status, normalized status, carrier timestamp, and a deterministic &lt;code&gt;dedupKey&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key is generated from four values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateDedupKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DedupKeyInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;carrier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeCarrier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trackingNumber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assertRequiredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trackingNumber&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;carrierStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assertRequiredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carrierStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;carrierStatus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;carrierTimestampIso&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeTimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carrierTimestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;carrierStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;carrierTimestampIso&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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 function is plain, but it carries the system. A carrier can send the same scan twice. A poll can retry after a timeout. A process can restart and fetch the same history again. If the fact is the same, the key is the same.&lt;/p&gt;

&lt;p&gt;What surprised me was how much of the gateway became simpler once duplicate handling moved into the event identity instead of the polling loop. The poller does not need to remember what it saw last time. The adapters do not need per-carrier duplicate caches. The WebSocket layer does not decide whether something is new. The processor decides once, against PostgreSQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The processor is the load-bearing part
&lt;/h2&gt;

&lt;p&gt;I was wrong at first about where the "real-time" complexity would live. I expected it to be WebSockets: connection cleanup, heartbeats, subscription maps, broadcast performance. Those were real problems, but they were not the hard correctness problem.&lt;/p&gt;

&lt;p&gt;The hard part was making sure Redis never became the source of truth by accident.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;createEventProcessor&lt;/code&gt; starts with a dedup lookup, then inserts into &lt;code&gt;tracking_events&lt;/code&gt; with &lt;code&gt;onConflictDoNothing()&lt;/code&gt;. That second guard matters because two workers can pass the lookup at the same time. The database still gets the final vote.&lt;/p&gt;

&lt;p&gt;After the insert, it updates the shipment projection only when the new carrier timestamp is newer than &lt;code&gt;last_event_at&lt;/code&gt;. Older events are still persisted. They just do not move the visible shipment state backwards.&lt;/p&gt;

&lt;p&gt;The important shape is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updatedRows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;currentStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalizedStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastEventAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carrierTimestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shipmentId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastEventAt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastEventAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carrierTimestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;projectionUpdated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updatedRows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the line between history and projection. An out-of-order scan belongs in history. It does not belong on the dashboard as the current state.&lt;/p&gt;

&lt;p&gt;Then Redis publish happens after the database work. If the database write fails, the processor returns &lt;code&gt;failed&lt;/code&gt; and does not publish. If Redis publish fails, the processor still returns &lt;code&gt;processed&lt;/code&gt; with &lt;code&gt;published: false&lt;/code&gt;. That asymmetry is deliberate. A WebSocket update can be missed. A carrier fact cannot be invented.&lt;/p&gt;

&lt;p&gt;The integration tests capture both sides. One test inserts an invalid shipment id and asserts that Redis stays empty. Another makes the publisher throw &lt;code&gt;redis unavailable&lt;/code&gt; and asserts the PostgreSQL event still exists. Those tests are more useful than a happy-path WebSocket demo because they pin down which failure the business can recover from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not broadcast directly?
&lt;/h2&gt;

&lt;p&gt;The simple version is tempting. A client subscribes to a tracking number. The processor receives an event. It loops over sockets and calls &lt;code&gt;send()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works until the process restarts, the send throws, or the event arrives in a worker that does not own the socket. Even in a single-process version, direct send ties event processing to live delivery. That is the dependency I wanted to avoid.&lt;/p&gt;

&lt;p&gt;Redis Streams gave the gateway a narrow delivery contract. The processor writes one stream entry to &lt;code&gt;tracking:events&lt;/code&gt;. The broadcaster consumes as group &lt;code&gt;ws-broadcaster&lt;/code&gt;, parses the payload, looks up connection ids by tracking number, and sends JSON to sockets registered in the current process.&lt;/p&gt;

&lt;p&gt;The broadcaster code has a small but telling rule: malformed stream entries are acknowledged.&lt;/p&gt;

&lt;p&gt;At first that felt wrong. Acknowledging a bad message means admitting it will never be delivered. But leaving invalid JSON pending forever is worse. It blocks operational visibility and makes the group look unhealthy for a message that cannot succeed on retry. The test for that case writes &lt;code&gt;{&lt;/code&gt; as the payload and asserts it gets logged and acked.&lt;/p&gt;

&lt;p&gt;The other Redis detail is &lt;code&gt;XAUTOCLAIM&lt;/code&gt;. If a consumer reads a stream entry and dies before acknowledging it, the message sits pending. The broadcaster reclaims stuck messages after 60 seconds. That is enough for the current single-service deployment, and it creates a clear upgrade path for multi-process delivery later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The polling loop stays boring on purpose
&lt;/h2&gt;

&lt;p&gt;The polling engine is less clever than the rest of the system. That is good.&lt;/p&gt;

&lt;p&gt;It loads enabled carrier configs from the database, starts one loop per carrier, queries active shipments excluding &lt;code&gt;delivered&lt;/code&gt;, &lt;code&gt;returned&lt;/code&gt;, and &lt;code&gt;deleted_at&lt;/code&gt;, then batches them by &lt;code&gt;POLL_BATCH_SIZE&lt;/code&gt;. The default is 10.&lt;/p&gt;

&lt;p&gt;Each shipment call runs through &lt;code&gt;withExponentialBackoff&lt;/code&gt;. &lt;code&gt;RateLimitError&lt;/code&gt; and &lt;code&gt;CarrierError&lt;/code&gt; are retryable by default. The delay is &lt;code&gt;baseDelayMs * 2 ** attempt&lt;/code&gt;, with the base coming from &lt;code&gt;carrier_configs.backoff_base_ms&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The scheduler has one job: skip overlapping cycles. If a carrier cycle is still running when the next interval fires, it returns a structured &lt;code&gt;skipped&lt;/code&gt; result with reason &lt;code&gt;cycle_already_running&lt;/code&gt;. It does not start a second poller and hope the processor dedup saves the day.&lt;/p&gt;

&lt;p&gt;That skip behavior matters because the PRD target is 100 active shipments across 3 carriers, with a carrier polling cycle under 30 seconds and WebSocket delivery under 200ms once the event enters the pipeline. If the poller stacks, the gateway creates its own load spike and every downstream metric lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timestamp bug that exposed the database shape
&lt;/h2&gt;

&lt;p&gt;The most useful gotcha came from pagination, not carrier integration. Cursor pagination repeated a shipment on page 2 even though the SQL condition compared the sort timestamp against the cursor timestamp.&lt;/p&gt;

&lt;p&gt;The schema uses PostgreSQL &lt;code&gt;timestamp without time zone&lt;/code&gt;. Passing a JavaScript &lt;code&gt;Date&lt;/code&gt; through Drizzle and node-postgres introduced enough interpretation drift that the comparison did not behave like the stored value. The fix was to format the cursor as a PostgreSQL timestamp literal and cast it with &lt;code&gt;::timestamp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is why &lt;code&gt;shipments.routes.ts&lt;/code&gt; has this helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toPgTimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;T&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks like formatting trivia. It is actually a pagination invariant. If the cursor repeats rows, clients may process the same shipment page twice or miss a page when they try to recover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I left deliberately unfinished
&lt;/h2&gt;

&lt;p&gt;The stats endpoint exposes a limitation instead of pretending the system has a metric it does not own yet. It returns active shipments and today's event counts by carrier, but &lt;code&gt;error_rate&lt;/code&gt; and &lt;code&gt;errors&lt;/code&gt; are &lt;code&gt;null&lt;/code&gt;. The response includes a &lt;code&gt;metrics_limitations&lt;/code&gt; entry that says carrier poll failure counters are not persisted yet.&lt;/p&gt;

&lt;p&gt;That is not a polished answer, but it is the honest one. The polling engine logs retry attempts and per-cycle errors, and the build journal records the summary fields: carrier, shipments polled, events found, deduplicated events, and errors. Those numbers exist at runtime. They do not yet survive as queryable history.&lt;/p&gt;

&lt;p&gt;I prefer that over a fake rate derived from current memory. A dashboard number that resets on restart is worse than no number because it invites operational decisions from incomplete evidence. If this gateway were being deployed for a client, the next increment would be a small poll-cycle table or metrics sink before anyone relied on &lt;code&gt;/api/stats&lt;/code&gt; for carrier health.&lt;/p&gt;

&lt;p&gt;The same honesty shows up in the success criteria. The system has the local loop, the production-start check, the Docker Compose smoke proof, and the mock-carrier journey. It does not have the 24-hour simulated-load soak. That missing box is not paperwork. It is the difference between "the architecture handles the failure model" and "this process survived a day of real runtime pressure."&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The finished local build passed 134 Vitest tests. The unit and integration coverage command passed at 83.32% statement and line coverage. The WebSocket E2E test publishes a Redis stream event and asserts delivery under 200ms after the broadcaster group exists. The mock-carrier demo registers DHL, DPD, and GLS shipments, subscribes over WebSocket, processes events through the real event processor, verifies duplicate suppression, and confirms the REST API shows in-transit shipments.&lt;/p&gt;

&lt;p&gt;The 24-hour soak is still deferred. That matters. A gateway like this is not production-proven just because the happy path works for a few minutes.&lt;/p&gt;

&lt;p&gt;But the failure model is in the right order. Carrier facts land in PostgreSQL first. Shipment state is a projection. Redis carries delivery. WebSockets are allowed to miss a push. The timeline is not allowed to lie.&lt;/p&gt;

</description>
      <category>eventsourcing</category>
      <category>redisstreams</category>
      <category>websocket</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Why I Made the Ledger Refuse Single Rows</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Tue, 05 May 2026 19:35:17 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/why-i-made-the-ledger-refuse-single-rows-37jo</link>
      <guid>https://dev.to/kingsleyonoh/why-i-made-the-ledger-refuse-single-rows-37jo</guid>
      <description>&lt;p&gt;The bug I kept designing around was not "wrong penalty amount."&lt;/p&gt;

&lt;p&gt;Wrong amounts are visible. A supplier sees a credit note for EUR 8400, checks the contract, and pushes back. The operator can investigate. The trail exists.&lt;/p&gt;

&lt;p&gt;The quieter failure is a penalty row with no mirror. One side of the system says the buyer is owed money. The other side never records the supplier-facing debit. The total looks correct in the dashboard because the credit row exists. The settlement builder can even pick it up. But the accounting story is incomplete, and it only becomes obvious when someone asks why the counterparty view does not match the buyer view.&lt;/p&gt;

&lt;p&gt;That is the sort of error append-only systems can preserve forever.&lt;/p&gt;

&lt;p&gt;So I made the ledger refuse single rows.&lt;/p&gt;

&lt;p&gt;The system calculates supplier SLA penalties. A missed response target, a delivery window breach, a compounding daily penalty, a tier crossing, or a per-ticket miss becomes money owed. That money then needs to move through three phases: accrual, possible reversal, and settlement. Each phase has a tempting shortcut.&lt;/p&gt;

&lt;p&gt;For accrual, the shortcut is one row with &lt;code&gt;amount_cents&lt;/code&gt; and &lt;code&gt;direction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For reversal, the shortcut is updating the original row.&lt;/p&gt;

&lt;p&gt;For settlement, the shortcut is marking the original row as settled.&lt;/p&gt;

&lt;p&gt;All three shortcuts make the data easier to write and harder to defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape That Forced the Design
&lt;/h2&gt;

&lt;p&gt;The PRD had a hard constraint: &lt;code&gt;penalty_ledger&lt;/code&gt; is append-only. Disputes, withdrawals, and corrections use compensating entries. Settlement membership lives outside the ledger. That sounds clean until the first application flow has to implement it.&lt;/p&gt;

&lt;p&gt;The accrual worker receives a breach, loads the contract and clause, calls &lt;code&gt;RulesEngine.calculatePenalty&lt;/code&gt;, and gets one answer back: no penalty, a domain error, an accrued amount, or a capped amount. The amount is not the ledger. It is only a financial fact waiting to become a record.&lt;/p&gt;

&lt;p&gt;The ledger record has more obligations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a credit side and mirror side&lt;/li&gt;
&lt;li&gt;same amount and currency&lt;/li&gt;
&lt;li&gt;same tenant, contract, counterparty, clause, and breach&lt;/li&gt;
&lt;li&gt;same accrual period&lt;/li&gt;
&lt;li&gt;same entry kind&lt;/li&gt;
&lt;li&gt;same compensation reference when reversing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those differ, the pair is not a pair. It is two rows that happen to be written near each other.&lt;/p&gt;

&lt;p&gt;I originally thought the database trigger was the center of the solution. Block update and delete. Done. That part exists:&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;trigger&lt;/span&gt; &lt;span class="n"&gt;penalty_ledger_block_update&lt;/span&gt;
&lt;span class="k"&gt;before&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;penalty_ledger&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;block_penalty_ledger_mutation&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;trigger&lt;/span&gt; &lt;span class="n"&gt;penalty_ledger_block_delete&lt;/span&gt;
&lt;span class="k"&gt;before&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;penalty_ledger&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;block_penalty_ledger_mutation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that only stops mutation after insertion. It does not stop a bad insert. A database that blocks updates can still store a malformed truth forever.&lt;/p&gt;

&lt;p&gt;That is where the F# domain layer earns its place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Load-Bearing Function
&lt;/h2&gt;

&lt;p&gt;The most important code in the ledger path is not the insert statement. It is &lt;code&gt;LedgerPair.create&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight fsharp"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nn"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cents&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nc"&gt;L&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nn"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cents&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nc"&gt;L&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="nc"&gt;LedgerAmountMustBePositive&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt;
        &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Direction&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;LedgerDirection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CreditOwedToUs&lt;/span&gt;
        &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Direction&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;LedgerDirection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Mirror&lt;/span&gt;
    &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="nc"&gt;LedgerPairDirectionInvalid&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EntryKind&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EntryKind&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="nc"&gt;LedgerPairKindInvalid&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Amount&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerPairMismatch&lt;/span&gt; &lt;span class="s2"&gt;"amount must match"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt;
        &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccrualPeriodStart&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccrualPeriodStart&lt;/span&gt;
        &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccrualPeriodEnd&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccrualPeriodEnd&lt;/span&gt;
    &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerPairMismatch&lt;/span&gt; &lt;span class="s2"&gt;"period must match"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sameContext&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerPairMismatch&lt;/span&gt; &lt;span class="s2"&gt;"tenant contract counterparty clause breach context must match"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CompensatesLedgerId&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CompensatesLedgerId&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerPairMismatch&lt;/span&gt; &lt;span class="s2"&gt;"compensating ledger reference must match"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccrualPeriodEnd&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;credit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AccrualPeriodStart&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="nc"&gt;PeriodInvalid&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="nc"&gt;Ok&lt;/span&gt;&lt;span class="bp"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That function is deliberately narrow. It does not know about HTTP, Hangfire, PDF rendering, Invoice Recon, or even settlement grouping. It only knows what makes two candidate rows a valid accounting pair.&lt;/p&gt;

&lt;p&gt;The private &lt;code&gt;LedgerPair&lt;/code&gt; type matters. Callers cannot construct one directly. They can propose two &lt;code&gt;LedgerEntryCandidate&lt;/code&gt; records, but only the domain module can expose the pair. That means the application layer does not get to "remember" to validate. It has no valid object unless validation has already happened.&lt;/p&gt;

&lt;p&gt;This is the part I got wrong at first in my head: I thought append-only was mainly a persistence rule. It is not. It is a construction rule. Once bad ledger rows are inserted, append-only protects the bad rows just as strongly as the good ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why The Rules Engine Stays Pure
&lt;/h2&gt;

&lt;p&gt;The penalty math also stays outside the database. &lt;code&gt;RulesEngine.calculatePenalty&lt;/code&gt; takes a &lt;code&gt;PenaltyCalculationInput&lt;/code&gt; and returns a result. No connection string. No repository. No clock except the explicit &lt;code&gt;AsOf&lt;/code&gt; value passed in.&lt;/p&gt;

&lt;p&gt;That was not aesthetic. The engine needs deterministic recompute. Given the same contract, clause, breach, prior accruals, and timestamp, it should return the same penalty. The snapshot test covers twelve cases: flat penalties, capped penalties, monthly fee proration, tier crossing, overflow tier behavior, currency mismatch, daily compounding cap, missing units, inactive clauses, and pre-contract breaches.&lt;/p&gt;

&lt;p&gt;The awkward case is previous accruals. Caps depend on what was already credited. Tiered penalties may need to accrue only the difference between the previous tier and the new one. Compounding daily penalties need to avoid adding the same days twice. So the rules engine receives &lt;code&gt;PreviousAccruals&lt;/code&gt;, filters to credit-side rows for the same clause, and calculates incremental money from that prior state.&lt;/p&gt;

&lt;p&gt;That looks like a database concern until it breaks. If the rules engine quietly queried the database itself, replay tests would become setup-heavy and timing-sensitive. By making prior state an input, the application layer owns retrieval and the domain layer owns the calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reversal Without Mutation
&lt;/h2&gt;

&lt;p&gt;The reversal path is where the design either holds or collapses.&lt;/p&gt;

&lt;p&gt;When a supplier disputes a breach and wins, the system cannot update the original accrual to zero. It cannot delete the row. It cannot mark the row as false and pretend the old financial position never existed.&lt;/p&gt;

&lt;p&gt;It writes a reversal pair.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ReversalEngine.uncompensatedCreditAccruals&lt;/code&gt; first looks for accrual rows that do not already have a reversal pointing at them. Then &lt;code&gt;reversalCandidate&lt;/code&gt; copies the amount, period, tenant, contract, counterparty, clause, and breach from the original accrual, changes the entry kind to &lt;code&gt;Reversal&lt;/code&gt;, and sets &lt;code&gt;CompensatesLedgerId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That little filter is important. Without it, the same breach could be reversed twice. Append-only would preserve both reversals, and the system would show the supplier owed less than zero. The code does not rely on a user avoiding the button. It filters uncompensated credit rows and writes every reversal inside one transaction with the status change.&lt;/p&gt;

&lt;p&gt;The design tradeoff is verbosity. A simple breach flow creates two rows on accrual. Accrual plus reversal creates four rows. A ledger explorer has to explain direction, entry kind, and compensation references. The UI has more work because the database refuses to simplify history for display.&lt;/p&gt;

&lt;p&gt;I accept that. Accounting systems should make the audit easy and the write path strict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settlement Is Not a Ledger Mutation
&lt;/h2&gt;

&lt;p&gt;Settlement introduced a second temptation: add &lt;code&gt;settlement_id&lt;/code&gt; to &lt;code&gt;penalty_ledger&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That would make queries simple. Find uncommitted rows where &lt;code&gt;settlement_id is null&lt;/code&gt;. Mark them when the PDF is built. Done.&lt;/p&gt;

&lt;p&gt;It would also puncture the ledger invariant. If building a settlement mutates a ledger row, the ledger is no longer an append-only record of penalty facts. It becomes a workflow table.&lt;/p&gt;

&lt;p&gt;The repo uses &lt;code&gt;settlement_ledger_entries&lt;/code&gt; instead. &lt;code&gt;SettlementsRepository.listUncommittedAccruals&lt;/code&gt; selects credit-side accruals for the period, excludes rows that already have active settlement membership, and excludes rows with reversals. Then &lt;code&gt;SettlementsRepository.insert&lt;/code&gt; writes the settlement row and membership rows in the same transaction.&lt;/p&gt;

&lt;p&gt;That means settlement membership is append-like metadata around the ledger, not a change to the ledger event itself.&lt;/p&gt;

&lt;p&gt;The cost is an extra table and a more careful query. The gain is that a settlement can be cancelled or released without rewriting what the penalty ledger said at the time of accrual.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That Still Needs Pressure
&lt;/h2&gt;

&lt;p&gt;The full suite passes: 12 domain tests, 6 data tests, 30 application tests, 10 API tests, and 3 UI tests. The dashboard load audit has tooling for &lt;code&gt;10000&lt;/code&gt; breaches and &lt;code&gt;5000&lt;/code&gt; settlements. The fake staging flow proves a Contract Lifecycle event can become an accrual, settlement, Invoice Recon outbox message, and &lt;code&gt;settlement.posted&lt;/code&gt; hub event in the local harness.&lt;/p&gt;

&lt;p&gt;But the PRD still has open success criteria around staged NATS-to-ledger time, Invoice Recon posting p95, and million-row ledger query behavior. That is the honest limit of the current proof. The invariants are strong. The operational envelope still needs live pressure.&lt;/p&gt;

&lt;p&gt;What surprised me is how much of the system exists to protect against its own future convenience. Every obvious shortcut would make one screen or one query easier. Single-row ledger entries. Updating rows for reversals. Marking ledger rows as settled. Reading tenant identity live during PDF posting.&lt;/p&gt;

&lt;p&gt;Each shortcut is fine until the first external audit, supplier dispute, or tenant rename.&lt;/p&gt;

&lt;p&gt;The lesson I took from this build is narrow: append-only is not a database trigger. It is a system-wide refusal to let later workflow needs rewrite earlier financial facts.&lt;/p&gt;

</description>
      <category>fsharp</category>
      <category>ledgerdesign</category>
      <category>domainmodeling</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Why I Refused to Re-Query the Tenant Row at Alert Dispatch Time</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Sun, 26 Apr 2026 17:37:42 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/why-i-refused-to-re-query-the-tenant-row-at-alert-dispatch-time-48m2</link>
      <guid>https://dev.to/kingsleyonoh/why-i-refused-to-re-query-the-tenant-row-at-alert-dispatch-time-48m2</guid>
      <description>&lt;p&gt;What does an audit-grade risk alert look like six months after the band crossing it describes, when the tenant has renamed itself twice in the interim, when the vendor has been merged into another, when the scoring rule has been retuned three times since? In a system that re-queries the source rows at dispatch time, the answer is whatever is true now. In this system, the answer is whatever was true at 14:03 on the Tuesday the alert was written.&lt;/p&gt;

&lt;p&gt;The concrete shape of the problem: a risk alert is created at 14:03 Tuesday for a vendor crossing from medium to high. The Notification Hub is down for emergency Postgres maintenance. The dispatcher falls back to the failed-alert retry queue and tries again every 30 minutes. By Friday afternoon, when the Hub is back, the email body must still read &lt;code&gt;Acme GmbH&lt;/code&gt;, the legal name that was current Tuesday, not the post-Wednesday rebrand to &lt;code&gt;Acme Industrial GmbH&lt;/code&gt; that the operator entered through the settings UI on Wednesday morning. A live re-query of the &lt;code&gt;tenants&lt;/code&gt; row at send time would emit the new name and reference an event at a company that didn't exist when it happened.&lt;/p&gt;

&lt;p&gt;That's the constraint this whole architecture is designed around. Not the failure mode I worried about most. The one I knew would happen and didn't have a clean answer to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem
&lt;/h2&gt;

&lt;p&gt;Most engineers I've talked to assume the issue is performance. They look at a table with a JSONB column called &lt;code&gt;delivery_payload&lt;/code&gt; holding a fully-rendered tenant snapshot, and they ask why I'd denormalize. The &lt;code&gt;tenants&lt;/code&gt; row has 11 identity columns. Why pickle them into JSON when I could just SELECT them again at dispatch time?&lt;/p&gt;

&lt;p&gt;Performance has nothing to do with it. The dispatcher is fast either way. What matters is that an alert is a fact about what was true at a moment in time, and a re-query produces a fact about what is true now. Those two facts are not interchangeable. An auditor reading the alert ledger six months later wants to know what the operator saw on Tuesday afternoon, not what the system shows today.&lt;/p&gt;

&lt;p&gt;Same problem on the report side. A vendor scorecard PDF generated in March needs to reprint byte-for-byte (or close enough to it) when an auditor downloads it again in September. If the underlying tenant or vendor row has been mutated in between, a live re-query produces a different document. The PDF that was approved by procurement leadership in March no longer exists on disk. It exists in the operator's email and nowhere else.&lt;/p&gt;

&lt;p&gt;The mistake is one of category. The system was treating mutable rows as the source of truth for an immutable record. SQL was doing exactly what SQL does: returning the current value of the column. The error was in the architecture asking the question that way at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraints
&lt;/h2&gt;

&lt;p&gt;Three things made this hard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenant identity is mutable by design.&lt;/strong&gt; A CPO can update their legal name, registration number, address, and brand colors at any time through the settings UI. We don't lock fields after creation; that would be operationally hostile to the business reality of mergers and rebrands. So the row is the live identity, full stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vendors get merged.&lt;/strong&gt; When the operator confirms that two &lt;code&gt;vendor_aliases&lt;/code&gt; rows point at the same legal entity, the system collapses them and the surviving vendor inherits the signals from the merged one. If an alert fires referring to "Vendor A" and that vendor gets merged into "Vendor B" three weeks later, a re-query at dispatch time would emit "Vendor B" in the email body. That isn't just confusing; it's wrong. The alert was about A.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries can run for days.&lt;/strong&gt; The notification hub has a circuit breaker (5-failure rolling window, 60-second cooldown). If the Hub is genuinely unhealthy for a sustained period, the failed-alert retry job picks alerts back up every 30 minutes and tries again. There is no upper bound. I have seen alerts retry across 72-hour outages. Anything the dispatcher reads at send time can have moved arbitrarily far from where it was at create time.&lt;/p&gt;

&lt;p&gt;The obvious moves were all bad. Locking the tenant row after first alert? Operationally hostile. Versioning every field on the tenant table with effective-date ranges? A second source of truth and a permanent maintenance burden. Re-creating the alert if the tenant changed? You can't, because the alert is a record of an event that already happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Design
&lt;/h2&gt;

&lt;p&gt;The architecture has two halves: storage immutability and rendering immutability. Both are necessary. Either alone is insufficient.&lt;/p&gt;

&lt;p&gt;On the storage side, every alert carries a &lt;code&gt;delivery_payload&lt;/code&gt; JSONB column populated at insertion. The capture path lives in &lt;code&gt;lib/alerts/capture_payload.rb&lt;/code&gt; and is called from the alert dispatcher exactly once, at the moment the &lt;code&gt;risk_alerts&lt;/code&gt; row is INSERTed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/alerts/capture_payload.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_score&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
  &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vendor_score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tenant&lt;/span&gt;
  &lt;span class="n"&gt;vendor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vendor_score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vendor&lt;/span&gt;
  &lt;span class="n"&gt;tenant_snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Tenants&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CaptureSnapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ss"&gt;event_type: &lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;tenant: &lt;/span&gt;&lt;span class="n"&gt;tenant_snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;vendor: &lt;/span&gt;&lt;span class="n"&gt;vendor_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_snapshot&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;score: &lt;/span&gt;&lt;span class="n"&gt;score_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_composite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_band&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;top_contributors: &lt;/span&gt;&lt;span class="n"&gt;top_contributors_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_score&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;deep_links: &lt;/span&gt;&lt;span class="n"&gt;deep_links_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="ss"&gt;created_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iso8601&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;deep_freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Tenants::CaptureSnapshot&lt;/code&gt; is the single canonical builder for the tenant identity block. Eleven columns from the &lt;code&gt;tenants&lt;/code&gt; row plus a &lt;code&gt;snapshot_at&lt;/code&gt; timestamp, returned as a frozen Hash. The shape is locked. Adding a column to &lt;code&gt;tenants&lt;/code&gt; does not automatically add it to the snapshot. Every addition is a deliberate change to the snapshot, the templates that bind to it, and the test fixtures.&lt;/p&gt;

&lt;p&gt;That's the first half. The dispatcher (&lt;code&gt;Alerts::HubDispatchJob&lt;/code&gt;) then has exactly one rule: read &lt;code&gt;alert.delivery_payload&lt;/code&gt; and never query &lt;code&gt;tenants&lt;/code&gt;, &lt;code&gt;vendors&lt;/code&gt;, or &lt;code&gt;vendor_scores&lt;/code&gt; again. The job's class doc is a paragraph explaining this. The integration test fires an alert, mutates the underlying tenant row, then runs the dispatcher and asserts the emitted Hub event still contains the original literal values. Without that test, a casual refactor could re-introduce a &lt;code&gt;Tenant.find(alert.tenant_id)&lt;/code&gt; call in the dispatcher, and the regression would only show up in production after the first sustained Hub outage.&lt;/p&gt;

&lt;p&gt;The second half is in-memory immutability. A frozen top-level Hash doesn't prevent a careless caller from mutating a nested Hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This still works on a top-level-frozen Hash:&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:tenant&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="ss"&gt;:legal_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Sneaky GmbH"&lt;/span&gt;
&lt;span class="c1"&gt;# raises FrozenError only if every nested level is also frozen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the capture path walks the structure recursively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deep_freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;
    &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;deep_freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;
    &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;deep_freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;String&lt;/span&gt;
    &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;obj&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Sidekiq worker that loads the JSONB column gets a fresh Ruby Hash from &lt;code&gt;JSON.parse&lt;/code&gt;, which is mutable, but the in-memory structure built by &lt;code&gt;CapturePayload.call&lt;/code&gt; is physically immutable at every level the moment it leaves the method. That removes a class of bugs where a logging middleware or a serializer transformation accidentally mutates the snapshot in flight before it's emitted to the Hub.&lt;/p&gt;

&lt;p&gt;The rendering side is the same idea applied to templates. Every Hub Liquid template registers with &lt;code&gt;strict_variables: true&lt;/code&gt;. Every report ERB template uses a small helper called &lt;code&gt;Reports::StrictFetch&lt;/code&gt; that walks dotted paths against the captured render context and raises &lt;code&gt;StrictFetchError&lt;/code&gt; on any unresolved segment. A template that references &lt;code&gt;{{ tenant.legal_nme }}&lt;/code&gt; (typo) doesn't silently emit an empty string. It raises during the test, before anything ships.&lt;/p&gt;

&lt;p&gt;The CI gate that locks all of this is in &lt;code&gt;test/integration/report_template_lint_test.rb&lt;/code&gt;. It renders every report template against captured render contexts for two distinct tenants, then parses each template for &lt;code&gt;f("…")&lt;/code&gt; calls without a &lt;code&gt;default:&lt;/code&gt; argument to enumerate the mandatory token set. The test will fail loudly if anyone adds a new template token without extending the snapshot, or adds a field to the snapshot but forgets to update a downstream template.&lt;/p&gt;

&lt;p&gt;I had this turned into the cleverest part of the suite by accident. After the first version of the test passed, I noticed it would also pass if I weakened &lt;code&gt;StrictFetch&lt;/code&gt; to silently return &lt;code&gt;nil&lt;/code&gt; for missing paths. So I added a deliberate-failure regression test: a synthetic broken template that references &lt;code&gt;{{ tenant.this_field_does_not_exist_anywhere }}&lt;/code&gt;. If anyone weakens the strict-fetch contract, that meta-test fails. Without it, the regression test for the regression test, the gate could rot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Surprised Me
&lt;/h2&gt;

&lt;p&gt;The byte-identical re-render test for the four report types did not work the first time, and the failure mode taught me something I would not have figured out from documentation.&lt;/p&gt;

&lt;p&gt;I picked the legal-footer line as my canary literal: &lt;code&gt;Reg: HRB-123456 · Tax: DE987654321 · Berlin, DE&lt;/code&gt;. The PDF visually rendered correctly. But &lt;code&gt;pdf-reader&lt;/code&gt; text extraction returned a &lt;code&gt;nil&lt;/code&gt; for that line. After two hours of debugging, I figured out that wkhtmltopdf encodes the &lt;code&gt;&amp;amp;middot;&lt;/code&gt; HTML entity as a non-standard glyph escape that pdf-reader's content-stream decoder doesn't recognize, and the entire PDF text object containing that escape gets dropped from the extraction.&lt;/p&gt;

&lt;p&gt;The fix was to pick canary literals from lines that don't contain &lt;code&gt;·&lt;/code&gt; separators (header &lt;code&gt;legal_name&lt;/code&gt;, address &lt;code&gt;line1&lt;/code&gt;, contact email are all safe). But the lesson was the deeper one: byte-identical PDF re-rendering is impossible because wkhtmltopdf embeds creation timestamps and random object IDs in every render. CSV outputs are bytewise equal across renders; PDFs are content-equal via text extraction. I had to pick the right level of the immutability claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;A 30-day audit-reprint test runs on every CI build. It captures a render context, generates the PDF and CSV, runs &lt;code&gt;Timecop.travel(30.days)&lt;/code&gt;, mutates the underlying &lt;code&gt;tenants&lt;/code&gt; row, and re-renders. CSV output is exactly byte-equal. PDF text-extracted output contains the original tenant literals (&lt;code&gt;Acme GmbH&lt;/code&gt;, &lt;code&gt;Hauptstraße 10&lt;/code&gt;, &lt;code&gt;procurement@acme-gmbh.example&lt;/code&gt;) and not the new ones. The same gate runs separately for the alert side: the dispatcher integration test fires an alert, mutates the tenant, runs the dispatcher, and asserts the emitted Hub event contains the pre-mutation legal name.&lt;/p&gt;

&lt;p&gt;The architecture is denormalized. Every alert duplicates the tenant identity. Every report context duplicates the entire scoring snapshot. Storage cost is real but small (typical alert payload is ~3KB; a year of band-crossing alerts at 50 vendors per tenant is well under 100MB per tenant). The cost is paid once at write time and never paid again at read time.&lt;/p&gt;

&lt;p&gt;The takeaway, if there is one: a snapshot has a different job from a normalized table. The normalized rows are operational state, useful for the live dashboard and the next score recompute, and entirely correct to mutate when the business changes. The frozen JSONB is the legal record. It's what you point at six months later when an auditor asks you to prove what the operator saw on a Tuesday afternoon in April.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>audittrails</category>
      <category>jsonb</category>
    </item>
    <item>
      <title>The Per-Tenant Rate Limit That Wasn't Per-Tenant</title>
      <dc:creator>Kingsley Onoh</dc:creator>
      <pubDate>Sun, 26 Apr 2026 17:33:43 +0000</pubDate>
      <link>https://dev.to/kingsleyonoh/the-per-tenant-rate-limit-that-wasnt-per-tenant-175b</link>
      <guid>https://dev.to/kingsleyonoh/the-per-tenant-rate-limit-that-wasnt-per-tenant-175b</guid>
      <description>&lt;p&gt;Tenant A blocked correctly at request 11. Tenant B also blocked at request 11.&lt;/p&gt;

&lt;p&gt;Phase 7 added a rate limit on &lt;code&gt;POST /api/events&lt;/code&gt; that reads from &lt;code&gt;tenants.config.rate_limits.events_per_minute&lt;/code&gt;. The default cap is 200 requests per minute. A tenant who needs more can be raised to 1000 via an admin &lt;code&gt;PATCH&lt;/code&gt;. The integration test seeded two tenants (one on the default, one bumped to 100) and asserted that tenant A got blocked at request 11 while tenant B kept going. Tenant B was supposed to get nine more requests through.&lt;/p&gt;

&lt;p&gt;The resolver function was fine. The unit tests on &lt;code&gt;resolveTenantEventsRateLimit()&lt;/code&gt; all passed. Pass a tenant config with no override, get 200. Pass a tenant with &lt;code&gt;events_per_minute: 100&lt;/code&gt;, get 100. Pass &lt;code&gt;null&lt;/code&gt;, get 200. Pass anything over 1000, get clamped to 1000. Five tests, all green. The function did exactly what it was supposed to do.&lt;/p&gt;

&lt;p&gt;The function was being called with &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Resolver Was Called With Null
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@fastify/rate-limit&lt;/code&gt; accepts a &lt;code&gt;max&lt;/code&gt; option that can be a function. The function receives the request and returns the cap to apply. Putting the resolver behind that callback was the design: each request asks "what is this tenant's limit?" at the moment the rate check runs, so changing a tenant's config takes effect immediately without a process restart.&lt;/p&gt;

&lt;p&gt;For the resolver to do its job, it needs &lt;code&gt;request.tenant&lt;/code&gt; already populated. That happens in &lt;code&gt;authPlugin&lt;/code&gt;, which reads the &lt;code&gt;X-API-Key&lt;/code&gt; header, looks up the tenant row, and attaches the tenant config to the request. The auth plugin runs in the &lt;code&gt;onRequest&lt;/code&gt; lifecycle hook.&lt;/p&gt;

&lt;p&gt;By default, &lt;code&gt;@fastify/rate-limit&lt;/code&gt; also runs in &lt;code&gt;onRequest&lt;/code&gt;. Fastify fires hooks in registration order within the same lifecycle stage. The rate-limit plugin was registered before the route was registered, and the route's auth runs as part of the route's plugin chain. The two &lt;code&gt;onRequest&lt;/code&gt; hooks fired in an order that meant rate-limit's &lt;code&gt;max&lt;/code&gt; callback ran first, with &lt;code&gt;request.tenant&lt;/code&gt; still undefined.&lt;/p&gt;

&lt;p&gt;The resolver, faithful to its contract, returned the default 200 for every request because every request looked like a tenant with no config override. Tenant A with the default and tenant B with 100 both got the same global cap. The endpoint behaved exactly as if the per-tenant feature didn't exist, but with no error and no warning, because both halves of the system were doing what they were told.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Moving Auth Wasn't an Option
&lt;/h2&gt;

&lt;p&gt;The fix could not move auth. &lt;code&gt;authPlugin&lt;/code&gt; runs in &lt;code&gt;onRequest&lt;/code&gt; for every protected route in the entire API, and most of those routes do not need rate limiting. Pushing auth to &lt;code&gt;preHandler&lt;/code&gt; to accommodate one route changes the lifecycle for every route, which moves a documented contract for one accidental dependency. That is the sort of fix that creates three new bugs to make one go away.&lt;/p&gt;

&lt;p&gt;The fix also could not run rate-limit globally with a different hook. The rate-limit plugin can be registered globally with a custom hook stage, but that overrides the stage for all rate-limited routes. Every other rate-limited route in the codebase, including the admin endpoints with their own static caps, would be affected by a change made for &lt;code&gt;events.routes.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What was needed was a per-route override that moved only this rate check to a later hook stage, leaving the rest of the system on defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  hook: 'preHandler' Plus a Real keyGenerator
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@fastify/rate-limit&lt;/code&gt; exposes a per-route option called &lt;code&gt;hook&lt;/code&gt; inside the route's &lt;code&gt;config.rateLimit&lt;/code&gt; object. Setting it to &lt;code&gt;'preHandler'&lt;/code&gt; means this specific route's rate check fires in the &lt;code&gt;preHandler&lt;/code&gt; stage instead of &lt;code&gt;onRequest&lt;/code&gt;. Auth has already run by then. &lt;code&gt;request.tenant&lt;/code&gt; is populated. The resolver gets a real tenant config and returns the right cap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;hook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preHandler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolveTenantEventsRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;keyGenerator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anonymous&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeWindow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 minute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in that block matter, and the &lt;code&gt;hook&lt;/code&gt; line is only one of them.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;keyGenerator&lt;/code&gt; is the second multi-tenant fix. The default key generator is the request IP. In a single-tenant deployment that is fine. In a multi-tenant deployment behind a shared NAT (two tenants whose servers happen to live in the same cloud region, two CI pipelines running tests behind the same egress address) the bucket gets shared. Tenant A burns through tenant B's allowance. The cap stops being per-tenant in a different way: the resolver returns the right number, but the bucket it counts against is wrong.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;keyGenerator&lt;/code&gt; to &lt;code&gt;request.tenantId&lt;/code&gt; partitions the bucket per authenticated tenant. The fallback to &lt;code&gt;request.ip&lt;/code&gt; covers the unauthenticated case (which the route does not allow, but defensive code is cheap and explicit defaults beat surprising ones).&lt;/p&gt;

&lt;p&gt;The third thing is the resolver clamp at 1000. The admin route validates input at 1-1000, but a tenant whose config gets corrupted, or an old tenant from before the validation existed, could in principle have a number out of range stored in their JSONB config. The resolver caps the return value defensively. A misconfigured tenant cannot DoS the platform by setting &lt;code&gt;events_per_minute: 999999&lt;/code&gt; directly in the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Single-Tenant Test Would Have Shipped This
&lt;/h2&gt;

&lt;p&gt;The hook-ordering issue was not in the documentation for &lt;code&gt;@fastify/rate-limit&lt;/code&gt; because it is not a bug in the plugin. The plugin defaults to &lt;code&gt;onRequest&lt;/code&gt; because that is the right stage for rate limiting in 99% of cases. You want to reject over-limit requests as early as possible, before any work happens. The Hub's case is the 1% where the rate check depends on data that is only available after another hook has run. The plugin gives you the escape hatch, but you only know to use it once you have hit the problem.&lt;/p&gt;

&lt;p&gt;The integration test that caught this was not a sophisticated test. It seeded two tenants, fired eleven requests for each, and asserted on the response codes. An earlier draft only seeded tenant A with a cap of 10 and asserted it got rate-limited at request 11. Tenant A was the only tenant. The test passed. I added tenant B with cap 100 specifically to verify that the per-tenant bucket worked, and the test failed with both tenants getting blocked.&lt;/p&gt;

&lt;p&gt;If I had only tested one tenant, the rate limit would have shipped looking like it worked. Every tenant on the platform would silently be on the same global cap, and the only way to discover it would be a tenant raising a support ticket about being blocked at unexpected request counts. That is the kind of bug that runs in production for months because nothing visibly breaks.&lt;/p&gt;

&lt;p&gt;The test fix was as simple as the production fix. Changing &lt;code&gt;hook: 'preHandler'&lt;/code&gt; and &lt;code&gt;keyGenerator: req.tenantId&lt;/code&gt; made the assertion pass. Total LOC change: two lines. Total time spent debugging from "why does tenant B also block" to "Fastify hook ordering": about an hour, most of it spent printing &lt;code&gt;request.tenant&lt;/code&gt; from inside the &lt;code&gt;max&lt;/code&gt; callback and confirming it was &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Else the Patch Quietly Got Right
&lt;/h2&gt;

&lt;p&gt;H7 ships with per-tenant rate limiting that actually scopes per tenant. Tenant default is 200 requests per minute, raised to a configurable value via &lt;code&gt;PATCH /api/admin/tenants/:id/rate-limit&lt;/code&gt;, capped defensively at 1000. The admin route uses spread-merge so updating &lt;code&gt;rate_limits.events_per_minute&lt;/code&gt; preserves the rest of &lt;code&gt;tenants.config&lt;/code&gt; (the channel credentials, the dedup window, the sandbox flag) without overwriting them. The PATCH was simple to write. Forgetting to use spread-merge would have been a different production incident: an admin update silently wiping every tenant's Resend API key.&lt;/p&gt;

&lt;p&gt;Test coverage went beyond the spec. The resolver got five tests covering null config, missing override, explicit override, cap-at-1000, and the default. The integration suite got five more covering the per-tenant bucket, admin happy path, range validation at both ends, 404 on missing tenant, and config preservation across the update. The admin tests assert that channel credentials and dedup_window survive the PATCH untouched. The spread-merge contract is the thing that has to keep working, not just on the day it was written, but on every future change to the admin route.&lt;/p&gt;

&lt;p&gt;The hook-ordering trick is now in the project's pattern 004 (per-route rate limit) as a note for the next route that needs a dynamic per-tenant cap. It will not be the last one. The same pattern will apply to per-tenant request limits on the suppressions endpoints, the templates endpoints, and any future endpoint where the cap depends on tenant config.&lt;/p&gt;

&lt;p&gt;The fix is one line. The understanding it required is the lifecycle of every plugin in the request chain and the order in which Fastify will call them. That is the kind of detail that does not show up in a unit test for a resolver function.&lt;/p&gt;

</description>
      <category>fastify</category>
      <category>ratelimiting</category>
      <category>multitenant</category>
      <category>plugins</category>
    </item>
  </channel>
</rss>
