diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,24 @@
 # Changelog for ExchangeAlgebra
 
+## 0.4.1.1 - 2026-06-07
+
+### Fixed
+- `union` (and therefore `(.+)` / `mappend` / `fromList`) misassociated a value
+  with the wrong base when one operand was a **zero-valued singleton**. For
+  `(v1:@b1) .+ (v2:@b2)` with `isZeroValue v1`, the result was `v2:@b1` — the
+  surviving nonzero value relabeled onto the *zero posting's* base (symmetrically
+  `v1:@b2` when `v2` was zero). A zero contributes nothing, so the result must be
+  `v2:@b2` / `v1:@b1` (the nonzero value on its **own** base).
+  The bug preserved `norm` (the total was unchanged) but corrupted **per-base
+  projection** (`proj` / `projWithBase` / `balanceBy` / stock & profit queries):
+  a value silently moved to a neighboring base. It was construction-order
+  sensitive — ledgers that build explicit `0:@base` singletons via the raw `(:@)`
+  constructor (e.g. sparsified input coefficients in agent-based simulations)
+  would, depending on accumulation order, invent a phantom posting on an adjacent
+  base. In the bundled simulation example this shifted a company's reported stock
+  by up to ~30% over 100 terms. One-line fix in `Algebra.hs union`, covered by the
+  new `testUnionZeroSingletonBase` unit test. (Independently confirmed root cause.)
+
 ## 0.4.1.0 - 2026-06-06
 
 ### Added
diff --git a/exchangealgebra.cabal b/exchangealgebra.cabal
--- a/exchangealgebra.cabal
+++ b/exchangealgebra.cabal
@@ -4,10 +4,10 @@
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: 7d4bb7802873f6ce2b4888ce4717621e5ccc4400b73ebc12b9d4ea69f0e994b5
+-- hash: fe5fab919197e65dce7b698dc174e0447987248ecd7a81108d260e187a1a248f
 
 name:           exchangealgebra
-version:        0.4.1.0
+version:        0.4.1.1
 synopsis:       Exchange Algebra for bookkeeping and economic simulation
 description:    Please see the README on GitHub at <https://github.com/yakagika/ExchangeAlgebra#readme>
 category:       Accounting, Finance, Math
diff --git a/src/ExchangeAlgebra/Algebra.hs b/src/ExchangeAlgebra/Algebra.hs
--- a/src/ExchangeAlgebra/Algebra.hs
+++ b/src/ExchangeAlgebra/Algebra.hs
@@ -643,11 +643,17 @@
 union Zero x  = x
 union x Zero  = x
 -- singletons
+-- NOTE: a zero-valued singleton contributes nothing, so the result must keep the
+-- /nonzero/ value on its OWN base. Earlier code returned @v2:@b1@ / @v1:@b2@,
+-- relabeling the surviving value onto the zero posting's base — this preserved
+-- 'norm' but silently moved the value to the wrong base, corrupting per-base
+-- projection and making construction order observable (raw @(:@)@ on a sparsified
+-- zero coefficient builds an explicit @0:@base@ singleton). Keep @v2:@b2@ / @v1:@b1@.
 union (v1:@b1) (v2:@b2)
     | isZeroValue v1 = case isZeroValue v2 of
                             True  -> Zero
-                            False -> v2:@b1
-    | isZeroValue v2 = v1:@b2
+                            False -> v2:@b2
+    | isZeroValue v2 = v1:@b1
     | otherwise      = insert b2 v2 (v1:@b1)
 -- If one side is a singleton
 union x (v:@b) = insert b v x
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -144,6 +144,25 @@
     assertEqual "bases Hat label count" 1 hatCount
     assertEqual "bases Not label count" 3 notCount
 
+-- | Regression test for the @union@ zero-singleton base-relabel bug
+-- (Algebra.hs). When one operand of @(.+)@ is a /zero-valued/ singleton on base
+-- @b1@ and the other a /real/ singleton on a different base @b2@, the result must
+-- keep the real value on its OWN base (@v2:@b2@), not relabel it onto the zero
+-- posting's base. The old code returned @v2:@b1@ / @v1:@b2@, which silently moved
+-- a value to the wrong base — preserving @norm@ but corrupting per-base projection.
+testUnionZeroSingletonBase :: IO ()
+testUnionZeroSingletonBase = do
+    let zb = 0 :@ (Hat :< Yen)    :: TestAlg   -- zero value, base Yen
+        rb = 5 :@ (Hat :< Amount) :: TestAlg   -- real value, base Amount
+    assertEqual "union zero(.+)real keeps real value on its own base"
+        rb (EA.proj [Hat :< Amount] (zb .+ rb))
+    assertEqual "union real(.+)zero keeps real value on its own base"
+        rb (EA.proj [Hat :< Amount] (rb .+ zb))
+    assertEqual "union zero(.+)real: nothing relabeled onto the zero's base"
+        (EA.Zero :: TestAlg) (EA.proj [Hat :< Yen] (zb .+ rb))
+    assertEqual "union real(.+)zero: nothing relabeled onto the zero's base"
+        (EA.Zero :: TestAlg) (EA.proj [Hat :< Yen] (rb .+ zb))
+
 testSigmaMergePath :: IO ()
 testSigmaMergePath = do
     let xs = [1 .. 5 :: Int]
@@ -655,13 +674,13 @@
     -- Stock at t=1
     assertSimNear "sim1 stock(t=1,c=1)" 28.487224703666264 (stocks1 !! 0)
     assertSimNear "sim1 stock(t=1,c=3)" 30.0               (stocks1 !! 2)
-    assertSimNear "sim1 stock(t=1,c=6)" 29.01925920375148  (stocks1 !! 5)
+    assertSimNear "sim1 stock(t=1,c=6)" 30.0               (stocks1 !! 5)  -- re-baselined: union zero-base fix removed a phantom self-input
     -- Stock at t=50
     assertSimNear "sim1 stock(t=50,c=1)" 304.9028131162567  (stocks50 !! 0)
     assertSimNear "sim1 stock(t=50,c=4)" 292.4764622201871  (stocks50 !! 3)
     -- Stock at t=100
     assertSimNear "sim1 stock(t=100,c=1)" 586.9595359862476  (stocks100 !! 0)
-    assertSimNear "sim1 stock(t=100,c=6)" 592.9260354913148  (stocks100 !! 5)
+    assertSimNear "sim1 stock(t=100,c=6)" 767.960563480499   (stocks100 !! 5)  -- re-baselined: union zero-base fix (bug compounded over terms)
     -- Gross profit at t=50
     assertSimNear "sim1 profit(t=50,c=1)" 0.35886554260018855 (profits50 !! 0)
     assertSimNear "sim1 profit(t=50,c=2)" 1.572544209772035   (profits50 !! 1)
@@ -771,6 +790,7 @@
     testProjWithNoteNorm
     testBasesNotSideRegression
     testNumericToleranceScaleAware
+    testUnionZeroSingletonBase
     testSigmaMergePath
     testSigma2When
     testSigmaFromMap
