Null GitHub issue

vibecode
{"vibecode": {
    "doc": "nulls",
    "role": "spec for Caspian's null model: bare-word null returns a fresh puck.uno/null instance per call; nulls carry an assignable flavor field; null/true/false are not user-overridable",
    "key_concepts": ["null_instance_per_call", "null_flavor", "no_singleton",
        "reserved_bare_words", "puck_uno_null_class"]
}}

null is a bare word method that returns an instance of the puck.uno/null class.

Too Long, Didn't Read GitHub issue

null returns a new instance of puck.uno/null. You do not get the same null object every time. Every null object has a flavor field to which you can assign anything.

Claude got pretty wordy in this document but it's a nice read if you're into the finer points of null.

Construction GitHub issue

vibecode
{"vibecode": {
    "section": "construction",
    "creation_method": "null",
    "per_call": "fresh_instance",
    "override": "not_allowed"
}}

Each invocation of the bare word null allocates a fresh instance of puck.uno/null. There is no singleton; $x = null; $y = null produces two distinct instances.

This is the simplest implementation: every null is a real object with its own storage. Optimizations (interning unflavored nulls, copy-on-write for flavor assignment, etc.) are possible later but are not required by the spec.

null cannot be overridden by user code. The same applies to true and false. A program that tries to redefine these names raises a runtime error.


Equality GitHub issue

vibecode
{"vibecode": {
    "section": "equality",
    "value_equality": "all_nulls_equal_regardless_of_flavor",
    "identity_equality": "distinct_instances_distinct_under_triple_equals",
    "flavor_comparison": "explicit_via_flavor_field"
}}

Equality on null is value-based: any null equals any other null under ==, even if their flavors differ. To compare flavors, read the flavor field directly.

$a = null
$a.flavor = :unknown

$b = null
$b.flavor = :timeout

$a == $b                     # true — both are null
$a.flavor == $b.flavor       # false — different flavors

To distinguish two distinct null instances by identity (each null is a fresh object), compare their .object views directly:

$a.object == $b.object       # false — distinct null instances
$a.object == $a.object       # true  — same null instance

There is no built-in === operator in Caspian. $foo.object == $bar.object is the spelling for "same object." Individual programs or libraries can define their own === if they have a use for it — it's not reserved.

To check whether a value is null at all (any flavor):

if $x == null
    # $x is some null
end

To check for a specific flavor:

if $x == null and $x.flavor == :timeout
    # $x is a null with flavor :timeout
end

Identity Guarantees GitHub issue

vibecode
{"vibecode": {
    "section": "identity_guarantees",
    "role": "states the engine-level guarantee that null/true/false instances always answer equality and predicate checks consistent with what they were created as; the mechanism is the read-only .object.bool property",
    "key_concepts": ["once_null_always_null", "user_code_cannot_redefine_identity",
        "layering_other_classes_and_fields_remains_open",
        "mechanism_is_dot_object_dot_bool_read_only"]
}}

The engine guarantees that any value created by null, true, or false always answers equality and identity checks consistent with what it was created as, regardless of what user code does to it afterward.

The mechanism is a read-only property on the universal object helper: every value has .object.bool set at creation, and user code cannot change it. The derived predicates (null?, defined?) read from bool and are likewise engine-enforced. See object.md for the full method set.

User code may:

User code may not:

The same applies symmetrically to true and false. The mechanism is .object.bool; the contract is the four predicate methods.

puck.uno/null is engine_only GitHub issue

The class itself declares engine_only: true at the class level (see base-class-use.md § engine_only). User code cannot push puck.uno/null onto another object's class stack:

$foo = 'bar'
$foo.object.classes.add 'puck.uno/null'    # raises — engine_only

This closes the loophole where a truthy object could be made to answer $x == null with true by inheriting the null class's == override. The override only applies to objects the engine itself created as null. puck.uno/false and puck.uno/true declare engine_only: true for the same reason.


Null Flavors GitHub issue

Null flavors let a program distinguish why a value is null, not just that it is null. The rest of this section explains the problem they solve, how the canonical healthcare standard (HL7) handles it, and Puck's deliberately simple implementation.

Background: The Challenge GitHub issue

vibecode
{"vibecode": {
    "section": "background_the_challenge",
    "role": "explains why a single NULL is insufficient — distinguishing reasons for missing data is often the most important information at the point of consumption"
}}

In most languages and databases, "missing" is one thing. A field is null, a row returns NULL, a function returns nil. That's the end of the story.

But in practice, "missing" is several things at once. Consider a patient record where the blood pressure field is empty:

Every one of these warrants a different downstream action. A reading that wasn't taken should probably be taken. A patient who refused should be respected. A not-applicable field should be skipped. A masked field signals that something exists you can't see.

A single shared NULL collapses all of these into "absent," forcing every consumer of the data to guess what the absence meant. The information that matters most — why the value is missing — is the very thing that gets lost.

This is not a healthcare-only problem. It shows up everywhere data flows through multiple systems:

Wherever distinguishing absence reasons matters, a single NULL forces awkward sentinel values, side-channel error codes, or out-of-band state tracking.

Background: How HL7 Handles It GitHub issue

vibecode
{"vibecode": {
    "section": "background_hl7",
    "role": "documents the canonical example of null flavors in HL7 v3 and FHIR healthcare standards",
    "key_concepts": ["formal_vocabulary", "hierarchy_of_reasons", "interop_across_systems"]
}}

HL7 (Health Level 7), the standards body for healthcare interoperability, has developed the most-formalized treatment of this problem. Both HL7 v3 and FHIR define a vocabulary of null flavors — codes that explain why a value is absent. A receiving system can distinguish reasons without ambient knowledge. The canonical reference is the HL7 v3 NullFlavor code system.

The HL7 null-flavor vocabulary:

Code Meaning
NI No information (the root — nothing is known about whether a value should exist)
UNK Unknown (a value should exist but is not known)
ASKU Asked but unknown (we asked, the patient/source doesn't know)
NASK Not asked (we didn't ask)
NAV Temporarily unavailable (information is expected but not available right now)
NA Not applicable (no proper value can apply for this case)
MSK Masked (a value exists but has been withheld)
INV Invalid (a value cannot be valid)
OTH Other (a value exists but isn't in the receiver's vocabulary)
TRC Trace (present in trace amounts)
QS Quantity insufficient (a result couldn't be produced; not enough sample)
NP Not present

The vocabulary is arranged in a hierarchy. NI (no information) is the root; UNK, NA, and MSK are children; ASKU and NASK are children of UNK. A consumer can ask "is this missing for any reason that's a kind of UNK?" and get a useful answer.

This works because every system speaking HL7 understands the same flavors. A lab that reports ASKU for a missing allergy field is communicating "we asked the patient, they didn't know" to every receiving system, and every receiving system acts on that information appropriately.

The price of the precision is the maintenance: the HL7 vocabulary is large, formally specified, and changes through a standards process. Adding a new flavor requires committee approval. The vocabulary is the right design for healthcare because the cost of getting absence wrong is high; for less safety-critical domains, the formality is overkill.

Other systems take similar approaches at different scales:

The pattern across all of these: when "missing" matters, distinguishing reasons pays off.

Puck's Approach GitHub issue

vibecode
{"vibecode": {
    "section": "puck_approach",
    "role": "summarizes the deliberately-simple Puck implementation: a single flavor field that accepts anything, with a small optional canonical vocabulary",
    "key_concepts": ["one_flavor_field", "free_form_value", "canonical_set_optional"]
}}

Puck takes the simplest possible approach to the same problem.

Each null instance has a single flavor field that can be assigned anything — a symbol, a string, a hash, an arbitrary object. Whatever the application needs to express why this value is null.

$x = null
$x.flavor = :unknown                              # standard symbol flavor
$x.flavor = "insufficient funds"                  # string flavor
$x.flavor = {code: :timeout, retry_after: 30}    # structured object flavor
$x.flavor = 'puck.uno/null/flavor/masked'        # canonical UNS flavor

There is no formal vocabulary baked into the language. There is no hierarchy. There is no committee. Puck ships a small canonical set of flavors under puck.uno/null/flavor/ for ecosystem interop, but applications are free to use any flavor values they want.

The trade-off vs. HL7's approach is deliberate: Puck's design is lighter and more flexible at the cost of giving up automatic cross-application semantic consistency. Two libraries might both use the symbol :unknown to mean different things, and there's no central authority that says they shouldn't. For most applications this is fine; if interop matters in a specific domain (healthcare, finance, etc.), that domain's conventions can be defined and adopted as community standards on top of the Puck primitive.

The flavor Field GitHub issue

vibecode
{"vibecode": {
    "section": "flavor_field",
    "field": "flavor",
    "type": "any",
    "default": "null"
}}

Every null instance has a flavor field. It defaults to null (an unflavored null) and accepts any value via assignment. Reading the flavor of a default null returns null:

$y = null
$y.flavor          # null

The flavor field is mutable. Assigning a new flavor replaces the previous one.

Standard Flavors GitHub issue

vibecode
{"vibecode": {
    "section": "standard_flavors",
    "namespace": "puck.uno/null/flavor",
    "intent": "shared_vocabulary_for_common_cases_application_specific_flavors_remain_free",
    "organization": "patterned_after_http_status_codes_with_numeric_codes_alongside_names",
    "classes": ["2xx_intentional", "3xx_redirection", "4xx_caller_reason", "5xx_provider_reason", "6xx_domain_extensions"]
}}

Canonical flavors live under the puck.uno/null/flavor/ namespace. They are intended for ecosystem interop — application-specific flavors remain free-form and are not constrained to this list.

The vocabulary is organized after HTTP status codes. Each flavor has both a name and a numeric code; the code follows HTTP conventions where the concept aligns and uses novel numbers where it doesn't. Classes by hundred:

Code UNS Meaning
200 puck.uno/null/flavor/explicit This null is the intended answer — null is correct here
301 puck.uno/null/flavor/moved Value lives at a different UNS (target in flavor.target)
304 puck.uno/null/flavor/not_modified No newer value than caller's last-seen
400 puck.uno/null/flavor/bad_request Caller asked for something malformed
401 puck.uno/null/flavor/unauthorized Auth required and not provided
403 puck.uno/null/flavor/forbidden Auth provided but insufficient
404 puck.uno/null/flavor/not_found No value at this address
408 puck.uno/null/flavor/timeout Operation exceeded its time bound
409 puck.uno/null/flavor/conflict Concurrent state prevents the answer
410 puck.uno/null/flavor/gone Used to exist; was permanently removed
423 puck.uno/null/flavor/locked Resource is locked by another holder
429 puck.uno/null/flavor/rate_limited Too many requests in window
500 puck.uno/null/flavor/internal_error Provider failed unexpectedly
501 puck.uno/null/flavor/not_implemented Operation not supported by this provider
502 puck.uno/null/flavor/bad_gateway Upstream provider returned an error
503 puck.uno/null/flavor/unavailable Provider temporarily can't answer
504 puck.uno/null/flavor/gateway_timeout Upstream provider didn't respond in time
507 puck.uno/null/flavor/insufficient_storage No room to fulfill
508 puck.uno/null/flavor/loop_detected Resolution cycled
600 puck.uno/null/flavor/destroyed Object's destroy was called (lifecycle)
601 puck.uno/null/flavor/not_applicable No proper value can apply (HL7 carryover)
602 puck.uno/null/flavor/unknown Value exists but is not known (HL7 carryover)
603 puck.uno/null/flavor/masked Value exists but is withheld (HL7 carryover)
604 puck.uno/null/flavor/not_set Field was never assigned
605 puck.uno/null/flavor/pending Not yet computed (lazy / async / in-flight)
606 puck.uno/null/flavor/cancelled Operation was explicitly cancelled
607 puck.uno/null/flavor/declined Handler chose not to process (pass-to-next pattern)
608 puck.uno/null/flavor/disconnected Connection lost
609 puck.uno/null/flavor/delete Tombstone — treat this entry as absent in a layered structure (e.g., meta_hash); the entry is explicitly removed, not just null-valued

Each canonical flavor exposes its code via flavor.code and its class prefix via flavor.class:

$x.flavor                # puck.uno/null/flavor/not_found
$x.flavor.code           # 404
$x.flavor.class          # '4xx'

A useful side effect of the HTTP-style organization: converting a null return into an HTTP response in web frameworks (Dogberry, Robinson, etc.) becomes mechanical — the 4xx/5xx code can be used directly as the HTTP status when the flavor maps cleanly.

The canonical set stays small relative to what's possible. Adding a new flavor to the canonical list is a deliberate ecosystem decision; new ones should be widely useful before being added. Application code that needs more specific reasons uses its own flavor values (symbols, strings, structured objects) without going through the canonical namespace.

When to Use a Flavor GitHub issue

A flavor is only useful if callers will actually branch on it. The rule: return a flavored null only when someone is expected to need the distinction.

A plain unflavored null says "no value here, deal with it however you want." Adding a flavor says "no value here, AND here's why, in case that matters to you." If no caller's logic differs based on the why, the flavor is noise — it adds surface area without informing anyone.

Don't preemptively flavor every null in the codebase. Use plain nulls by default. Add a flavor when:

Many cases don't qualify. %puck returning a plain null when its chain slot is empty is an example: there's nothing for callers to distinguish ("no puck here" is the whole story; no further branch is implied).

Flavor Propagation GitHub issue

vibecode
{"vibecode": {
    "section": "flavor_propagation",
    "rule": "operations_that_consume_null_discard_its_flavor",
    "rationale": "flavor_describes_why_this_value_is_null_does_not_extend_to_replacement_value"
}}

Operations that consume a null and produce a non-null value discard the flavor. The flavor describes why a particular null was null; once that null has been replaced by a real value, the explanation is no longer relevant to the new value.

$x = null
$x.flavor = :timeout

$y = $x or "default"
# $y is "default" (a fresh string from the literal source).
# The flavor :timeout does not transfer to $y.

If a developer needs to preserve the flavor across an or-style fallback, they must do so explicitly:

$x = null
$x.flavor = :timeout

if $x == null
    $reason = $x.flavor
    $y = "default"
    # $reason holds :timeout for the developer to use as needed
end

Use Cases GitHub issue

vibecode
{"vibecode": {
    "section": "use_cases",
    "role": "shows where null flavors are most useful in the Puck ecoverse"
}}

Null flavors carry information that single-NULL systems lose. Common applications:

Serialization GitHub issue

vibecode
{"vibecode": {
    "section": "serialization",
    "rule": "unflavored_null_serializes_as_native_null_flavored_null_serializes_as_typed_hash_via_id_marker",
    "key_concepts": ["round_trip_preserves_flavor",
        "unflavored_uses_native_null", "flavored_uses_id_marker_in_classes_hash",
        "keeps_bucket_namespace_open"]
}}

Flavor survives serialization. A flavored null written to a Mikobase record, a worldlet, or any other persistent form retains its flavor and reconstitutes as a flavored null on read.

The serialization rule:

Example (Mikobase record — instances use the canonical {bucket, stack} shape; class-definition records use whole-hash form, but these are instances):

json
{
    "bucket": {
        "agent_response": null,
        "user_status": {
            "bucket": {"flavor": "declined_to_answer"},
            "stack": {
                "2": {"class": "puck.uno/null"}
            }
        },
        "device_reading": {
            "bucket": {"flavor": "puck.uno/null/timeout"},
            "stack": {
                "2": {"class": "puck.uno/null"}
            }
        }
    },
    "stack": {
        "3": {"class": "foo.com/measurement"}
    }
}

A nested object in the bucket carries its own {bucket, stack} and is recognized by that shape. On read, the engine sees the inline platter stack and reconstructs an instance of the named class with the captured bucket fields. Anything expecting a null still sees a null (per the equality rules above).

This pattern is how all nested typed objects round-trip — flavored nulls, inline class instances, anything that needs class identity. The nested {bucket, stack} keeps the host bucket's namespace open without requiring sidecar tables or ID-marker conventions.

(Whether inline nested class instances persist as a pattern in Mikobase at all, vs always using puck.uno/reference to separate records, is an open design question — see #341.)

The flavor value itself must be representable in the target format. Symbol and string flavors round-trip cleanly. Hash flavors round-trip if the hash is itself serializable. Flavors holding live objects (functions, capabilities, etc.) cannot survive serialization — the engine raises if asked to serialize one.

Relation to the role model GitHub issue

vibecode
{"vibecode": {
    "section": "relation_to_role_model",
    "role_and_flavor": "independent"
}}

A flavored null is a puck.uno/null instance with a populated field. It participates in the role model like any other value (see roles.md): the null has an owning role (from the role of the code that created it) and a flavor. The two are independent — flavor does not affect the owning role, and vice versa.

The owning role does not survive serialization (storage breaks the role chain by design). Flavor does, per the rule above. The distinction is intentional: the owning role describes where this value came from in the current program, which loses meaning across a serialization boundary; flavor describes what the value is, which carries through.

© 2026 Puck.uno