Base class use GitHub issue

Stale. Flagged in issue 364. The concepts here are about 90% right and the doc is useful as historical context, but the platter model is being redesigned from scratch rather than patched. Treat specific details here — per-platter bucket shape, hash-keyed-by-UUID layout, pinned/mutable region split, sticky/active flags, marker-class examples — as previously proposed, not current spec. Other docs that lean on this one (Trivet's per-platter storage, garbage collection's on_close multicast across platters, Mikobase requirements, lucy.md's object model) inherit the same caveat where they cite this doc's mechanisms.

vibecode
{"vibecode": {
    "doc": "base_class_use",
    "role": "exploring per-instance class chains as a primary mechanism for adding behavior to objects in Caspian — instead of subclassing, augment plain values with classes at runtime; also proposes per-platter private storage by enriching the classes array entries",
    "status": "stale; see banner at top of file and issue 364. Concepts ~90% right; redesign-from-scratch is the path forward.",
    "key_concepts": ["per_instance_class_chain", "tag_classes", "marker_classes",
        "capability_stamps", "behavior_addition_at_runtime",
        "alternative_to_subclassing", "sticky_vs_removable",
        "chain_order", "method_conflict",
        "platter_metaphor", "per_platter_bucket", "platter_records",
        "class_vs_classes_resolution"]
}}

This document explores an alternative way to add behavior to objects in Caspian: per-instance class chains that can be modified at runtime. Instead of declaring a subclass to extend a base type, a program adds a class directly to an instance's chain. The instance keeps its original base class but gains the methods, identity, and markers contributed by the added class.

Caspian already supports this mechanism; this doc is about formalizing it as a design pattern and exploring what it enables.

The pattern GitHub issue

The canonical example:

caspian
$foo = 'string'
$foo.object.classes.add 'foo.uno/upper'

puts $foo    # STRING

$foo starts as a plain string. Adding foo.uno/upper to its class chain augments the instance — puts dispatches through the chain, finds foo.uno/upper's to_string override, and emits the uppercased form.

The instance is still a string. It's just also a foo.uno/upper. The class chain holds both, and method dispatch walks the chain.

Dispatch order (per lucy.md § The Class Stack): the shadow class is consulted first, then the class stack is walked from top to bottom. The class an object was created with sits at the bottom (first pushed). Subsequent .classes.add calls push to the top — so later additions shadow earlier ones. There's no privileged "base class"; the class an object is born with is just the first in the stack.

This is why the foo.uno/upper example works: adding it puts it above the original string class, so its to_string wins dispatch.

Canonical example: marker classes GitHub issue

The simplest case is a class with no methods, just identity. The engine and other code can read whether the marker is present to make decisions.

The cleanest concrete example is puck.uno/class/redact (see snapshot redaction):

caspian
@password = nil
$pw = secure_input()
$pw.object.classes.add 'puck.uno/class/redact'   # mark as sensitive
@password = $pw

The marker class adds nothing to $pw's behavior during normal execution. At snapshot time, the engine walks the class chain, sees the marker, and emits null instead of the password's contents.

This is the cleanest possible use of per-instance class chains — the class is purely a tag the engine reads.

What this pattern enables GitHub issue

Trade-offs to resolve GitHub issue

Pinned and mutable regions GitHub issue

The class stack has two regions:

Visualized:

top of stack
   ↓
   ┌─────────────────────┐
   │ shadow              │  ← pinned, empty by default
   │ (other pinned)      │  ← pinned (e.g., truthiness)
   ├─────────────────────┤  ← pinned/mutable boundary
   │ <most recent add>   │  ← top of mutable region
   │ ...                 │
   │ <original class>    │  ← bottom of mutable region
   └─────────────────────┘
   ↓
bottom of stack

Pinned platters are added at instantiation by the engine and locked at their absolute position. The pinned region grows downward as the engine adds more — newer pinned platters attach below older ones.

.classes.add is engine-aware. It finds the pinned/mutable boundary and inserts above the top of the mutable region but below the pinned region. User code can't accidentally push something into the pinned region.

Attempts to manipulate platters in the pinned region fail. Calling .classes.remove(<pinned-id>) raises; calling .classes.move(...) on a pinned platter raises.

Sticky platters in the mutable region can be moved within the mutable region but cannot be removed. If a sticky platter drifts upward and touches the bottom of the pinned region, it gets absorbed into the pinned region (locks at that position).

Dispatch after the unification GitHub issue

Method resolution is now just "walk the stack top to bottom" — no special rule for the shadow class. The shadow happens to be at the top because it's a pinned platter at the top, not because of a separate rule. The same is true for the puck.uno/truthiness platter when present — it's a pinned platter in the pinned region, participating in normal dispatch order. See object.md § Mechanism for how the truthiness platter encodes null / false / true via a single class with a bucket-carried truthy field.

$foo.object.classes returns all platters in stack order (top to bottom), including the shadow platter at position 0. Programs that want to iterate only "user-relevant" platters can filter by sticky/class-uns/etc.

engine_only class property GitHub issue

A class can declare engine_only: true at the class level. The engine then refuses any .classes.add call that targets that class — only the engine itself can push such a platter (typically during primitive instantiation).

This is the mechanism behind locked-at-instantiation identity properties. puck.uno/truthiness declares engine_only: true, so a program can't fake its way into changing an object's truthiness by adding the platter after the fact. Same idea applies to any future identity-bearing platter that must only exist when the engine put it there.

The property is at the class level, fixed for the class's lifetime. Unlike sticky (which is per-platter and tracks "can this be removed from this object"), engine_only tracks "can user code create instances of this on any object." Both properties can apply to the same class — typically do, since identity-bearing platters are both pinned and engine-only.

There's no opt-out. The developer's "out" is to instantiate the object they actually want — not to mutate identity after the fact.

Three immutable primitives GitHub issue

null, true, and false are special: their class stacks are fully locked. You cannot:

The whole object — class stack, shadow, bucket — is engine-frozen.

Why: if any program could mutate true or null, every truthiness check everywhere becomes unreliable. The cost of safety here is absolute — these primitives sacrifice flexibility entirely so everything else can rely on them.

The shape of a null (illustrative):

json
{
    "classes": {
        "<shadow-id>": {"class": "puck.uno/class/shadow",  "bucket": {}},
        "<truthiness-id>": {"class": "puck.uno/truthiness",    "bucket": {"truthy": null}}
    },
    "bucket": {}
}

The shape of a false:

json
{
    "classes": {
        "<shadow-id>": {"class": "puck.uno/class/shadow",  "bucket": {}},
        "<truthiness-id>": {"class": "puck.uno/truthiness",    "bucket": {"truthy": false}}
    },
    "bucket": {}
}

The shape of a true is the same as false but with bucket: {truthy: true}. (Or bucket: {} — the absent and truthy: true encodings are equivalent.)

Both platters are pinned. puck.uno/truthiness is also engine_only (see above), which is what blocks .classes.add 'puck.uno/truthiness' on a previously-truthy object. The entire primitive is immutable — adding or removing anything raises.

Alternative, not replacement GitHub issue

Subclassing still exists in Caspian. This pattern is a different way to compose behavior, not the only way.

When to use which:

The two coexist because they solve different problems.

Per-platter private storage GitHub issue

A class in the stack is a platter (mental metaphor: LP records stacked on a turntable). Today every platter shares the same %bucket for instance data — collisions between platters are the developer's responsibility, and mix-ins manage the problem with the soft uns.<UNS> namespace convention.

Proposed change: each platter gets its own private bucket, formalizing what the uns.<UNS> convention has been doing by hand.

Proposed shape GitHub issue

Today the object's universal shape is {bucket, classes}, where classes is a list of class references:

json
{
    "bucket": {},
    "classes": [
        "foo.bar/gup",
        "foo.bar/bear"
    ]
}

The proposal changes classes from an array of class names into a hash keyed by platter ID, with each value being a platter record (the class reference plus its own private bucket):

json
{
    "bucket": {},
    "classes": {
        "<id-1>": {"class": "foo.bar/gup",  "bucket": {}},
        "<id-2>": {"class": "foo.bar/bear", "bucket": {}}
    }
}

Platter IDs are UUIDs generated fresh per allocation from libsodium. Object IDs use the engine's global sequencer (integer-strings), but platter IDs cannot — they appear as keys inside user buckets (via the per-platter-marker mechanism in nulls.md § Serialization) where they need to be collision-safe against arbitrary user-chosen field names. An integer-string "7" could collide with a user bucket key; a UUID's 128-bit address space can't, in practice.

Every UUID comes fresh from libsodium per call. No caching, no seeded PRNG. Caches and PRNG state are attack vectors — anyone who can read them gets future UUIDs, which matters because Mikobase record_pks leak externally through worldlet exports and query results. Cache-based optimizations were considered and rejected on security grounds; see #354.

Why a hash, not an array of {id, class, bucket} records: the ID is the platter's natural identifier, so putting it in the key position (instead of repeating it inside each value) is cleaner and gives O(1) lookup. Caspian's hashes preserve insertion order, so the class stack's dispatch order (top to bottom) is maintained naturally — the engine's .classes.add method handles "add to the top of the stack" without exposing hash-internal positioning to the user.

The top-level object's shape stays {bucket, classes} — same two keys, no new universal reserved key. What changes is the shape of the classes field: hash instead of array, entries are platter records keyed by ID.

Design rationale GitHub issue

Two strong constraints shaped this:

The platter-record shape resolves both: top-level shape is unchanged (still {bucket, classes}), and the bucket-no-reserved- keys rule is preserved (it now applies recursively — both the top-level bucket and each platter's own bucket are free-form).

Bucket policy GitHub issue

The bucket field has no requirements beyond being a hash.

This applies to every bucket in the system — the top-level object bucket, every platter's private bucket, and any future bucket the language introduces. The rules are minimal and uniform:

The point of this policy: a bucket is purely the class designer's storage. No mental overhead about which keys the runtime cares about, no future-proofing against possible reservations, no collision risk with framework conventions. The hash inside is yours.

The per-platter bucket design above is what makes this policy tenable. Without per-platter buckets, mix-ins had to namespace their state somewhere — the soft uns.<UNS> convention was the workaround. Per-platter buckets give every class its own designated space, so no class has to encroach on another's bucket via key conventions. The convention becomes obsolete.

Bucket allocation GitHub issue

Open: whether each platter's bucket field is always allocated (uniform shape) or lazy (only appears once written). Lean: always present, for uniformity.

Accessing buckets from inside a class GitHub issue

Shared object bucket (the default — what classes write to in the common case):

caspian
@foo = 'bar'              # shorthand for %bucket['foo'] = 'bar'
%bucket['foo']            # explicit

Platter's own private bucket (the special case — explicit syntax, no sugar):

caspian
%platter['foo'] = 'bar'   # writes to this platter's own bucket
%platter['foo']           # reads from this platter's own bucket

The asymmetry is deliberate. By default, a class writes to the shared object bucket — same as today, no behavioral change for existing code. Per-platter private storage is the rare case, and the explicit %platter[...] syntax serves as a friction point that signals "I'm doing something less common." No @@foo-style shorthand for the platter-bucket case; if you want platter isolation, you write it out.

The mental pairing:

Source Resolves to
@foo %bucket['foo'] (shared object bucket)
%bucket['foo'] shared object bucket, explicit
%platter['foo'] this platter's own bucket

%platter returns the current platter's bucket — the bucket field of the platter record corresponding to the class currently executing. The engine knows which platter is active from method dispatch (same mechanism that tracks the active class for call_with and friends).

What this resolves GitHub issue

class vs classes — a long-standing tension resolved GitHub issue

A side benefit of this structural change: the word class has historically done double duty in Caspian — sometimes meaning "the kind of thing this hash is" (identity, the hash's own class), sometimes meaning "a class as a value passed around" (a reference, e.g., a query target). Code with both meanings in proximity is hard to read.

By moving object identity to the classes field (plural, a hash of platter records keyed by ID), the singular word class is freed from identity duty. When class appears as a parameter, field name, or variable, it's unambiguously "a class reference."

The platter record's class key is exactly that — a class reference — so using class as the key is now natural and descriptive, not an overload:

json
{"class": "foo.bar/gup", "bucket": {}}

Compare to before, where the same word class would have been the storage identity key ({class: "foo.bar/gup", bucket: ...} read as "this hash IS a foo.bar/gup"). Same shape, but the meaning shifts: now it reads as "this platter REFERENCES foo.bar/gup."

class remains the class declaration keyword, unchanged. The tension was at the field-name level, not the keyword level.

Implications for other docs GitHub issue

Settled changes that propagate:

Active and inactive platters GitHub issue

Each platter carries an optional active field. The default is true, and the field is omitted when it would be true — only inactive platters explicitly carry active: false. So most platter records have just {class, bucket} (or {class, bucket, sticky} if sticky); only deactivated platters add the field.

.classes.remove(<id>) doesn't actually remove the platter — it sets active: false. The platter's bucket and metadata stay in the classes hash. The rationale is simple: rather than trace whether the deactivated platter's bucket data is still needed by something else, just keep it around. Removals are rare enough that the small accumulation cost is worth not tracing.

What active: false affects:

What active: false doesn't affect:

Sticky platters cannot be deactivated. "Can't be removed" extends to "can't be set inactive." Pinned-region platters and user-marked sticky platters stay active for the object's lifetime by definition.

Reactivation.classes.set_active(<id>, true) flips the field back. The platter rejoins normal dispatch with its bucket intact.

Snapshot/revive carries inactive platters through. Roundtrip is exact.

Method resolution GitHub issue

Method dispatch is two-dimensional — for any given method lookup, the engine walks across the platter stack AND through each platter's class's inherits chain. Both dimensions get explored, with a discipline to avoid loops and duplicate work.

The walk:

  1. Start with an empty visited set (per-dispatch, not cached).
  2. Iterate the platter stack from top to bottom.
  3. For each platter, walk up its class's inherits chain. At each class encountered:
  1. First match wins. If no platter yields a match across its inheritance chain, the method is unknown — raise puck.uno/error/method_missing (or similar).

Unicast vs multicast dispatch GitHub issue

The walk above is unicast — first match wins, stop. That's the default for normal method dispatch ($foo.bar).

Some methods are multicast instead — same walk, but every match invokes. The engine collects all matching handlers across platters and inheritance chains, then invokes each in walk order (top platter first, ancestor-class match before sibling-platter match per the visited set's encounter order).

Dispatch kind Stops at Used by
Unicast First match Regular method calls ($foo.bar)
Multicast End of walk Class-body lifecycle hooks (on_close, after_set, etc.)

The two share the walk; they differ only in stopping rule. Multicast isn't a special case grafted onto dispatch — it's a second mode of the same walk, with a different terminator.

How dispatch kind is declared GitHub issue

Dispatch kind is a property of the function object, not a keyword in the declaration. Functions carry an on_call field; the value selects between dispatch modes:

on_call value Dispatch
:first (or absent) Unicast — first match wins
:all Multicast — every match fires
caspian
class
    function &on_close($call)
        @socket.close
    end
    $on_close.on_call = :all      # declares this as multicast

    function &to_string()
        ...
    end
                                  # unicast (default)
end

Property assignment goes immediately after the function definition by convention — the two-line pair carries the visibility a keyword would have. See lucy.md § on_call property for the full design (mutability, caching consequences, future properties in the same family).

on_close is the canonical multicast case — every platter that defines on_close gets to clean up its own state, not just the top one. See garbage-collection.md § Multicast across platters. The same model covers after_set / after_delete on hashes and any future class-body lifecycle hook — see #343.

Properties:

No MRO caching at this stage. Caspian permits dynamic class mutation (methods added or removed at runtime, classes loaded mid-program), which makes MRO caches unsafe without invalidation discipline. The per-dispatch visited-set approach is correct under any class mutation. If real workloads later show dispatch as a hot spot, more sophisticated caching with explicit invalidation can be added as an optimization — but the correctness guarantee (no stale dispatch) stays.

%platter during inheritance walks. When a method comes from an inherited class (not the platter's own class), %platter still returns the platter that initiated the inheritance walk — the per-instance platter, not the resolved ancestor class. This keeps per-platter bucket access stable regardless of where the method came from.

call_with semantics. $obj.object.call_with($class, 'method', ...) dispatches explicitly to the named class. Could optionally walk up that class's inheritance chain if the named class doesn't define the method — TBD as the API matures.

Open design questions GitHub issue

To be filled in as the pattern crystallizes.

© 2026 Puck.uno