24 KiB
SBCL's redefinition model, as a reference point for Flan
Research notes, 2026-09-20. Untracked. Written for DISCUSS.org's item "investigate SBCL's redefinition model — warn + keep old value until callers update". Nothing here is a decision; the last section lists candidates and stops.
The question behind it: Flan's dev session hard-refuses a struct layout change
and a global type change (lib/session.ml:304-338, docs/BUILT.md:1213-1219),
and plan.org's dev/release table (~671-682) lists "Structs: version word" for dev
builds with nothing behind it. SBCL is the system the author half-remembered as
"warn, keep the old layout, complain at the stale reader". It does do something
close to that — but the shape of it is not quite what the note says, and the
difference matters for what Flan could copy.
1. defstruct redefinition
It is an error, not a warning
This is the one place DISCUSS.org's summary is wrong, and it is worth stating
first because it inverts the precedent. The note says SBCL "warns rather than
refusing". SBCL signals a continuable error — the default behaviour of an
incompatible defstruct redefinition at a REPL with no handler is to drop you
into the debugger, and in a non-interactive build it aborts. It proceeds only
because a human (or a handler) picks a restart.
From src/code/defstruct.lisp, %redefine-defstruct:
"attempt to redefine the ~S class ~S incompatibly with the current definition"
with two restarts:
continue— "Use the new definition of ~S, invalidating already-loaded code and instances."recklessly-continue— "Use the new definition of ~S as if it were compatible, allowing old accessors to use new instances and allowing new accessors to use old instances." Offered only whenmutable-layout-pholds, i.e. when the new layout can be overwritten in place onto the old one (compatible sizes/rawness), so it is not offered for arbitrary changes.
There is a separate genuine warning for the compatible-ish case,
redefine-structure-warning:
"incompatibly redefining slots of structure class
S@Make sure any uses of affected accessors are recompiled"
The comparison that drives all of this is compare-slots, which returns three
lists: slots that moved, slots that were retyped, slots that were
deleted. So SBCL's notion of "incompatible" is finer than Flan's
compatible_structs all-or-nothing name+type list equality — a pure append of
trailing slots is a different case from a reorder, and SBCL distinguishes them.
Note also that ANSI leaves structure redefinition undefined
(CLHS defstruct); everything above is an SBCL extension, not a standard
protocol. SBCL is not implementing a spec here, it invented a policy.
What happens to the live instances
Picking continue calls register-layout with :invalidate t, which reaches
%invalidate-layout in src/code/class.lisp:
"Mark LAYOUT as invalid. This is called only on CONDITION and STRUCTURE subtypes when redefining incompatibly."
It sets the layout's invalid slot, zeroes layout-clos-hash, and unhooks the
classoid from every superclass's subclass table. The instances themselves are
not touched, not walked, not found. There is no heap scan. Each instance keeps
pointing at the same layout object it always pointed at; that object is now
flagged invalid.
This is the crucial mechanism, and it is the one Flan cannot have: an SBCL structure instance carries a pointer to its layout in its header word. The invalidation is one store into a shared object, and every instance in the heap learns about it for free, because they all reach it through that pointer.
When the error surfaces
At the next access, not at redefinition time. Two paths, and they give different conditions, which is worth being precise about:
-
The typed-accessor path. A
defstructaccessor in safe code checks the instance's layout against the expected one. With the layout invalidated the check fails and you get an ordinarytype-error— the value is no longertypepthe struct type it used to be. (I did not read the exact accessor emission path insrc/compiler/; treat "it is specifically atype-errorrather than some dedicated condition" as unverified.) -
The PCL path. When a structure-object with an invalid wrapper goes through PCL (generic function dispatch,
slot-value),%obsolete-instance-trapfires. For a structure it signalssb-pcl::obsolete-structure, defined insrc/pcl/std-class.lisp, reported as:"obsolete structure error for a structure of type ~S"
This is the condition people quote when they say "SBCL tells you your instance is stale". There is no restart on it that repairs the instance. There is no update protocol for structures.
So the honest one-line summary of the struct story: SBCL refuses by default,
proceeds only on an explicit human restart, and then converts your old instances
into landmines that raise at first touch. It never silently reads at the wrong
offsets — except under recklessly-continue, which is exactly the "silent
argument mismatch" outcome docs/BUILT.md:1223-1230 says Flan refuses to ship,
and SBCL offers it only behind a name chosen to shame you.
2. defclass redefinition — the part with a real protocol
This is where CL earns the reputation, and it is a standard protocol (CLHS
4.3.6, update-instance-for-redefined-class), not an SBCL extension.
Redefining a class does not error. The sequence:
-
The new class definition is installed.
make-instances-obsoleteruns — in SBCL, thestd-classmethod calls%update-lisp-class-layoutand%invalidate-wrapperwith the:obsoleteflag. Again: a flag on the shared wrapper, no heap walk.layout-invalidcan holdnil,t,(:flush <wrapper>)or(:obsolete <wrapper>);invalid-wrapper-pis the predicate on the access path. -
Nothing else happens until someone touches an instance. CLHS 4.3.6: the update occurs "at an implementation-dependent time, but no later than the next time a slot of that instance is read or written", and the instance's
eqidentity is preserved across it. -
On that first touch,
%obsolete-instance-trapruns. It computes the added slots and the discarded slots by name, builds a property list of the discarded slots' values, swaps the instance's storage for the new shape, and calls:(sb-sys:nlx-protect (update-instance-for-redefined-class instance added discarded plist) (replace-wrapper-and-slots instance owrapper oslots))The
nlx-protectis a detail worth noting: if the user'supdate-instance-for-redefined-classmethod signals and the stack unwinds, the instance is rolled back to its old wrapper and slots rather than left half-migrated.
What the protocol guarantees:
- Slots present in both definitions keep their values, matched by name. This is the whole reason it works, and it is only possible because a CLOS instance's slots are addressed through a name→index map held in the class, not baked into compiled call sites.
- Added slots are initialized from their
:initforms — the system-supplied primary method callsshared-initializeon exactly the added-slot names, passing along the initargs it received. - Discarded slots' values are not lost, they are handed to you in the
property list, so a user method can do
(getf plist 'old-name)and derive the new slots from the old ones (the canonical CLHS example is a cartesian→polar coordinate change). - Initargs are validated; the default method signals on an initarg not declared valid for the class.
- The return value is ignored.
The pattern to take away: the automatic part is name-matching, and the interesting part is a user hook that gets the old values. CLOS does not try to guess what a renamed slot meant; it hands you the corpse and lets you decide.
3. Global variables
Trivial, as expected, and the interesting bit is that CL already draws Flan's distinction.
defparameterre-evaluated "unconditionally assigns the initial-value to the dynamic variable named name".defvar"assigns initial-value (if supplied) to the dynamic variable named name only if name is not already bound".
That is precisely docs/BUILT.md's rule that a defvar's initial value is
deliberately not in the refusal table — "edit the code, keep the sand". CL's
defvar is the same policy, reached by the same reasoning, thirty years earlier.
Flan's defvar and CL's behave the same way here: both accept a changed
initialiser and ignore it while the variable is bound. The divergence is only in
the const twin — Flan's defconst refuses a changed value (when the checker
consumed it), whereas CL's defparameter simply overwrites.
There is no type-change problem to speak of, because a CL special variable
has no compile-time type and no fixed-shape storage — it is one boxed word. The
entire class of failure that lib/session.ml:304-311 is protecting against
(storage laid out to a type, reused for another) does not exist in CL. So CL
offers no precedent for Flan's typed-global refusal; it sidesteps it by being
dynamically typed. Worth saying plainly rather than pretending there is guidance
here.
defconstant is the exception and is famously annoying: redefining one to a
non-eql value is an error in SBCL, which is the same shape as Flan's
compile-time-consumed defconst refusal, for a related reason (the old value
may already have been folded into compiled code).
4. Function redefinition
Trivially supported, and the reason is the reason Flan's cell design already
works: a global function call goes through the fdefinition, a name→function
indirection, not to a body address. Redefining foo stores a new function
object into the symbol's function cell, and every call site compiled against
foo picks it up on its next call with no recompilation and no stale caller.
This is exactly plan.org's "every cross-function call goes through an
indirection cell; body redefinition is one atomic pointer store". Flan already
matches SBCL here for the body-only case.
Two nuances worth carrying over:
- Stale callers do exist in SBCL, in one place: block compilation. Under
(declaim (start-block ...))/:block-compile, calls within the block become local calls resolved at compile time. Redefining a function in a block-compiled unit leaves its in-block callers calling the old body — they were never going through the fdefinition. SBCL's answer is not a warning or a trampoline; it is "block compilation prevents redefinition, that is the trade you made", and the feature is off by default. Self-recursive calls are the same story in miniature: SBCL deliberately compiles a self-call as a full call precisely so that a function can be redefined while running. - SBCL does not version signatures. A function whose lambda list changes is
just a redefinition; there is no new version, no trampoline, no per-caller
warning. Callers compiled against the old arglist get a normal
wrong-number-of-arguments error at runtime (or a compile-time style-warning if
SBCL still has the old ftype recorded and sees the call again). plan.org's
versioned-function design with tracked caller sites is more than SBCL does,
not a port of it. If the author was remembering SBCL as the source of that
design, the memory is of something else — the nearest real relatives are
Erlang's two-version code loading (old code + current code, a process running
old code is killed on the third load) and Smalltalk's
become:.
5. What maps to Flan and what cannot
The structural obstacle, stated once
Every SBCL mechanism above — struct layout invalidation, CLOS wrapper obsolescence, the lazy trap — rests on a single fact: an instance carries a pointer to its shape descriptor in its header. That is what makes "invalidate the shape" an O(1) operation that reaches every live instance, and what makes "detect staleness at next access" a load-and-compare on a word the instance already has.
Flan's typed side has none of that. A defstruct value is flat and unboxed; it
lives inline in a global, inline in an array element, in registers, spilled on a
stack frame. There is no header word, no per-instance shape pointer, and no way
to enumerate the live instances of a type. So:
- Layout invalidation as SBCL does it: structurally unavailable. There is nothing to invalidate that instances reach.
- Detecting an obsolete instance at access time: unavailable in release shape, because detection requires a per-instance word. It is available only if you add that word, which is exactly what plan.org's "Structs: version word, dev only" line means.
update-instance-for-redefined-class-style migration: doubly unavailable, because it needs both the per-instance shape tag and the ability to change an instance's size in place. A flat struct embedded in an array cannot grow.
The dev-build version word is SBCL's generation counter
The connection plan.org's table implies but does not spell out: a version word
in the dev build is the degenerate form of SBCL's layout. SBCL's instance points
at a layout object whose invalid flag can be flipped; a Flan dev-build struct
would carry an integer stamped at construction, compared against the type's
current generation on access. Flip the generation on redefinition, and the next
field access on an old-generation value traps. That is layout-invalid with the
indirection collapsed into an immediate, which is the only form that survives
"instances have no headers, but dev builds may pay for one extra word".
What it buys, honestly: not the ability to keep using old values. It buys a
diagnosis instead of a refusal — the reload is accepted, the program keeps
running, and the first read of a stale value says "this Cursor was built
before you changed Cursor" with a location, instead of the session saying
"restart" before anything runs. That is genuinely SBCL's bargain (continue
then trap), and it is the honest framing: the version word converts a
compile-time refusal into a runtime trap. Whether that is an improvement is a
judgement about which failure the author would rather debug.
What it costs, all of which is real:
- A word per struct instance in dev builds, so dev and release layouts differ — which means array strides, FFI structs, and anything crossing to C differ too, or need to be excluded. The dev/release divergence plan.org already accepts for frames and cells gets bigger and more observable.
- A check on every field access in dev builds.
- Structs embedded in other structs, in arrays, and in
Vecs each need a story; an array of 4096 cells would need every element stamped and every element checked. - Globals are not covered by it at all. A global's storage is a fixed slot with a shape; a version word on the global could detect a stale read, but there is nothing to read — the point of the refusal is that the old bytes mean something else now. SBCL has no precedent to offer, per section 3.
The dyn side is where CLOS-style update is actually available
Grounding the claim in the code as landed (FIX.org item 6, commits 8d2bf2a,
5af990e, 6c6024e; runtime/flan_dyn.c):
- A
defclassinstance is an ordinary dyn map with aklassfield in theflan_objheader (flan_dyn.c:255), set byflan_dyn_map_new_class(:1088), read byflan_dyn_class_of(:1100). It is deliberately in the header and not an entry, solen,renderanddyn_equaldo not see it. - Slots are keys.
lib/classes.ml:150-175:(defclass point [x y])desugars to(defn point [x dyn y dyn] dyn #point{:x x :y y})and nothing else. Access isget/putby keyword.
Two consequences, and the second is the one that surprised me:
- Name-based slot carrying — the hard half of
update-instance-for-redefined-class— is free here. A dyn instance already stores its slots by name. Any migration is a map operation. - There is no class object at runtime to invalidate, because there is no class
registry at all. The
klassfield holds an interned keyword, not a pointer to a class descriptor. Redefining adefclasstoday is just redefining its constructordefn— which the session already permits as an ordinary body change. Old instances keep their old keys and keep working; new ones get the new keys;class-ofanswers the same keyword for both. So the dyn side currently has neither the refusal nor the protocol: it has silence.
Whether that silence is a bug depends on what the author wants. It is the Clojure answer (a map is a map; a "class" is a tag) rather than the CLOS answer. The CLOS answer would need, concretely:
- A runtime class registry: keyword → (slot-name list, generation counter).
Nothing like this exists today;
lib/classes.mlis compile-time only and emits a plaindefn. - A generation stamp per instance, or a cheaper trick: compare the instance's
key set against the registry's current slot list lazily on
get/put. With a generation word in theflan_objheader — there is room, and unlike the typed side these objects have headers — the check is a compare. - A migration step at first access after a bump: add missing slots as nil,
collect removed slots into a property list, and call a user hook. The obvious
Flan spelling of that hook is a generic function, e.g.
(defmethod update-for-redefined point [p added discarded] ...), which fits the dispatch mechanism that already exists. - A decision on whether
putof an unknown slot stays legal. Today it is — a class instance is an open map, and FIX.org already defers "refusing an unknown slot at(get p :z)". If unknown slots stay legal, the registry's slot list is advisory and the whole update protocol is advisory with it.
This is a real, SBCL/CLOS-precedented design that Flan's runtime can actually support. It is also a feature with no user yet, since redefinition on the dyn side currently fails silently rather than loudly.
6. Candidate designs
Three, none recommended over the others. Cost estimates are rough.
A. Keep the refusal for typed structs and globals; fix the message
Leave lib/session.ml refusing, but rewrite both messages to say what SBCL's
restarts say: name which fields moved, were retyped, or were deleted (SBCL's
compare-slots split — Flan compares whole field lists and can cheaply do the
same split), and state the two things the author can do instead (restart, or
rename the type and migrate by hand). Optionally accept the case SBCL's
mutable-layout-p accepts and Flan currently refuses: appending fields to a
struct no array/FFI type depends on is layout-compatible for every existing
instance, so it need not be refused at all.
- Cost: small, days. Frontend only, no runtime change, no dev/release divergence.
- Behaviour: unchanged except the append case starts working and the diagnostic names the offending field.
- Precedent: SBCL refuses by default too — this is the
%redefine-defstructerror with no restart taken, pluscompare-slots' finer classification. The "SBCL warns instead of refusing" premise in DISCUSS.org does not survive contact with the source, so "keep refusing" is the SBCL-consistent option, not the timid one.
B. Dev-build struct version word — SBCL's layout generation, collapsed
Stamp every dev-build struct instance with the type's generation at
construction; bump the generation on an incompatible redefinition; check the
stamp on field access in dev builds and trap with "this value was built before
Cursor changed shape" instead of refusing the reload. Release builds carry
neither word nor check, which is plan.org's dev/release table as written.
- Cost: large. Touches layout, both backends, arrays and
Vecs of structs, FFI boundary exclusions, and every field-access site; the dev/release layout divergence becomes observable wherever a struct crosses to C. This is the one that wants a written design before any code. - Behaviour: the reload is accepted and the program keeps running; stale values trap loudly at first touch rather than reading wrong offsets. It does not let old values keep working — no migration is possible for a flat unboxed struct — so it converts a pre-run refusal into a mid-run trap. Globals are not covered and still need option A's refusal.
- Precedent: SBCL's
%invalidate-layoutplus the obsolete-instance trap, with the layout pointer replaced by an immediate because Flan instances have no headers. Note that SBCL, having the same choice, still puts the error in the redefinition path as well and requires a restart to reach the trap state.
C. CLOS-style update for dyn defclass instances only
Give defclass a runtime registry (keyword → slots + generation), stamp a
generation into the flan_obj header beside klass, and on the first get/put
after a bump, migrate the instance: add new slots as nil, gather removed slots
into a list, and dispatch a user-overridable generic update-for-redefined
before returning. Typed structs and globals are untouched and keep option A's
refusal.
- Cost: medium and well-contained —
runtime/flan_dyn.cheader field plus a registry,lib/classes.mlemitting the registration, one lazy check on the map access path. It rides on machinery that already exists: headers, interned keyword tags, generic dispatch, and slots that are already addressed by name. - Behaviour: redefining a class stops being silent. Instances survive
redefinition with their shared slots intact, new slots appear as nil, and the
author can write the coordinate-change-style migration by hand. The risk is
that it is a ceremony on top of maps that are already open — if
putof an arbitrary key stays legal, the registry describes an intention rather than a constraint. - Precedent: this is CLHS 4.3.6 and
update-instance-for-redefined-class, the only part of CL's story that is a standard protocol rather than an implementation's policy, and the only part whose prerequisites Flan already satisfies.
B and C are independent and could both happen, or neither. A is close to free and is compatible with both.
Sources
- SBCL source,
src/code/defstruct.lisp(%redefine-defstruct,compare-slots,redefine-structure-warning,mutable-layout-p, restart texts) — https://github.com/sbcl/sbcl/blob/master/src/code/defstruct.lisp - SBCL source,
src/code/class.lisp(%invalidate-layout,register-layout,layout-invalid) — https://github.com/sbcl/sbcl/blob/master/src/code/class.lisp - SBCL source,
src/pcl/std-class.lisp(obsolete-structurecondition,%obsolete-instance-trap,make-instances-obsolete,invalid-wrapper-p,replace-wrapper-and-slots) — https://github.com/sbcl/sbcl/blob/master/src/pcl/std-class.lisp - CLHS 4.3.6 Redefining Classes — https://www.lispworks.com/documentation/HyperSpec/Body/04_cf.htm
- CLHS
update-instance-for-redefined-class— https://www.lispworks.com/documentation/HyperSpec/Body/f_upda_1.htm - CLHS
defparameter/defvar— https://www.lispworks.com/documentation/HyperSpec/Body/m_defpar.htm - SBCL Internals, Local Calls — http://www.sbcl.org/sbcl-internals/Local-Calls.html
- Block compilation in SBCL 2.0.2 — https://mstmetent.blogspot.com/2020/02/block-compilation-fresh-in-sbcl-202.html
- Lisp journey, "Structures: lightweight records" (the user-visible restart menu) — https://lisp-journey.gitlab.io/blog/data-structures-structs/
The SBCL manual (https://www.sbcl.org/manual/) does not document structure redefinition, obsolete instances or layout invalidation at all — checked, and it is silent. Everything in sections 1 and 4 above comes from source and mailing list/blog material, not from the manual.
Marked unverified
- The exact condition signalled when a compiled typed accessor (as opposed to
the PCL path) is applied to an instance with an invalidated layout. Stated
above as "an ordinary
type-error"; I did not read the accessor emission insrc/compiler/, so treat the specific condition name as unverified. Thesb-pcl::obsolete-structuretext is verified fromsrc/pcl/std-class.lisp. - Whether SBCL's
compare-slotsclassification ever avoids the error for a pure append (i.e. whether appending slots is accepted silently). Themutable-layout-pgate onrecklessly-continuestrongly implies a compatible-layout notion exists, but I did not confirm the append case is error-free.