It turns out you can! I'm not sure if it's really the canonical answer, but it certainly makes some sense, and you can even hack them up in ML, basing them off the existing exn type. The idea is that you can generate new tags at runtime and build records with fields labelled by these tags.
As an extra bonus, over lunch today tom7 showed me how phantom types can be cleverly used to simulate row polymorphism. I made one small (I think) improvement, using the built-in product type instead of an abstract type of phantom conjunctions. This means writing the necessary static coercions requires no brainpower to generate the appropriate tangle of combinators. You just write the old shape as a tuple pattern, and the new shape as a tuple, and it works.
Here, then, is the code for "Phantom Extensible Products": (One small caveat; I suspect strongly in the absence of full understanding that putting a functor inside a structure means I am doing "higher-order modules" even though taking a tuple of a function doesn't really increase the order. NJ seems to love this shit, but Idunno what other compilers do. Anyone know if there's a way around having to do things this way?)
signature PXPROD = sig type 'c xrec type ('a,'b) tag val empty : unit xrec (* "safe set" --- tracks the added field in the type *) val sset : 'c xrec -> ('a,'b) tag -> 'a -> ('b * 'c) xrec (* "unsafe set" --- forgets that it was added. Not really unsafe per se, but I can't think of a better name. Its forgetting of the addition does not contribute to safety, anyhow. *) val uset : 'c xrec -> ('a,'b) tag -> 'a -> 'c xrec val spi : ('b * 'c) xrec -> ('a,'b) tag -> 'a (* "safe projection" --- if we provide evidence that the record contains a certain field, we get its data unqualified *) val upi : 'c xrec -> ('a,'b) tag -> 'a option (* "unsafe projection" --- if we have no such evidence, we can at least get out maybe SOME of its data, or NONE *) val coerce : ('c -> 'd) -> 'c xrec -> 'd xrec end signature TAG = sig type content type ('a,'b) tag type stag val dtag : (content, stag) tag end signature PXP = sig type ('a,'b) tag structure Pxprod : PXPROD where type ('a,'b) tag = ('a,'b) tag functor NewTag(type content) : TAG where type content = content where type ('a,'b) tag = ('a,'b) tag end structure Pxp :> PXP = struct type ('a,'b) tag = ('a -> exn) * (exn -> 'a option) structure Pxprod = struct type 'c xrec = exn list type ('a,'b) tag = ('a,'b) tag val empty =  fun sset d (ti,tt) x = (ti x) :: d val uset = sset fun upi (h::tl) (ti,tt) = (case (tt h) of SOME x => SOME x | NONE => upi tl (ti,tt)) | upi  t = NONE fun spi xrec t = Option.valOf (upi xrec t) fun coerce f x = x end functor NewTag(type content) = struct type content = content type ('a,'b) tag = ('a,'b) tag type stag = unit val dtag : (content, stag) tag = let exception E of content in (E, (fn y => ((raise y) handle E x => SOME x) handle _ => NONE)) end end end structure Test = struct open Pxp open Pxprod structure I = NewTag(type content = int) structure S = NewTag(type content = string) structure R = NewTag(type content = real) structure B = NewTag(type content = bool) val R = R.dtag val I = I.dtag val S = S.dtag val B = B.dtag val r = empty val r = sset r I 601 val r = sset r S "foo" val r = sset r B false val r = sset r R 3.14 val r = uset r R 2.17 val r = uset r S "bar" fun drop r = coerce (fn (x,y) => y) r fun arrange r = coerce (fn (r,(b,(s,(i,x)))) => (b,(i,(s,x)))) r (* val assemble : (B.stag * (I.stag * (S.stag * 'a))) xrec -> (bool * int * string * real option) *) fun assemble x = (spi x B, spi (drop x) I, spi (drop (drop x)) S, upi x R) val result = assemble (coerce arrange r) end