Memory management / Declared memory groups

Moth is reference-semantic by default, copy-explicit and move-inferred. It omits explicit reference types and lifetime syntax, not references themselves.

A declared memory group is a hard semantic lifetime region. Values placed into a group belong to that group for their full lifetime. Accepted design with implementation deferred.

Declared memory groups

Design contract

A declared memory group is a hard semantic lifetime region. Values placed into a group belong to that group for their full lifetime. They may retain references to allocations owned by the same group or by a region statically known to outlive the group. No group-owned value, projection, or alias may outlive the group.

Group identity is semantic lifetime metadata. It is not a value, type, field, parameter, generic argument, trait, allocator object, or lifetime annotation, and it must not enter TypeId or a source signature.

Moth is reference-semantic by default, copy-explicit and move-inferred. It omits explicit reference types and lifetime syntax, not references themselves.

Status

Accepted end-state design with implementation deferred. This page is the canonical semantic authority for group / into. Implementation sequencing, prerequisites and deferred optimisation investigations live in docs/roadmap/plans/grouped-memory-design.md. Current implementation support lives in the progress matrix.

Relationship to the general memory model

Grouped memory extends, rather than replaces, Moth's existing memory model:

GC must not weaken group escape rules. A GC-only backend may ignore physical group release, but it must accept and reject the same grouped source as every other backend.

Canonical general topology rules, including the one-owner invariant, the stored-edge outlives rule, narrowing and widening, cycles and projection families, live in docs/src/docs/codebase/memory-management/lifetime-regions-and-escape-validation/.

Terminology

Lexical scope

A source and HIR scope that controls name visibility and control-flow exits.

Implicit lifetime region

A compiler-inferred lifetime owner for ordinary ungrouped allocations. The compiler may merge, widen, split or physically ignore implicit regions when behaviour remains unchanged, subject to the nearest-existing-ancestor rule.

Declared lifetime region

A source group name: block. It is a hard lifetime boundary. The compiler must not silently widen it to make an invalid escape legal.

Physical arena

One possible backend representation for one or more lifetime regions. An arena is not a source value and is not synonymous with a semantic region.

Runtime reference

A target representation that locates a runtime value, such as a JavaScript object reference, Wasm offset, pointer or table index.

Destruction responsibility

The obligation to release or safely transfer storage when deterministic release is enabled. The unified ownership bit represents this responsibility. It does not prove uniqueness.

Lifetime owner

The one semantic region responsible for keeping an allocation alive and eventually ending its lifetime.

group block


    group request:
        parsed ParsedPost into request = parse_post(post)
        html String into request = render_post(parsed)
    ;
    

        name [access/type] into group_name = expression
    

Rules:

The group name is local to the current executable body scope. It cannot collide with a visible value, type, import, constant, reactive source or active group. A group is not a value. Groups are invalid in constants, config, signatures, fields, choices, traits and export surfaces.

into declaration placement


    parsed into scratch = parse_post(post)
    parsed ParsedPost into scratch = parse_post(post)
    rows ~{Row} into scratch = {}
    maybe_name String? into scratch = find_name(id)?
    

into group_name appears after access or type syntax and before =.

V1 has no expression-site placement. Prefer:


    row Row into scratch = parse_row(raw)
    ~rows.push(row) catch:
        assert(false, "unexpected push failure")
    ;
    

Do not initially add:


    -- INVALID: expression-site placement is not supported in V1
    ~rows.push(parse_row(raw) into scratch) catch:
        assert(false, "unexpected push failure")
    ;
    

Placement stays attached to closed receiving boundaries.

Destination scope and visibility

A declaration targeting an ancestor group is a narrow V1 escape mechanism, not a general definite-assignment system.

Canonical V1 rule:

Valid nested placement:


    group request:
        group scratch:
            parsed ParsedPost into scratch = parse_post(post)
            html String into request = render_post(parsed)
        ;

        use(html)
    ;
    

Invalid branch placement:


    group request:
        if condition:
            html String into request = render_post() -- invalid in V1
        ;

        use(html)
    ;
    

Conditional replacement:


    group request:
        html String into request = if condition:
            then render_post()
        else
            then render_fallback()
        ;
    ;
    

Invalid loop placement:


    group request:
        loop posts |post|:
            html String into request = render_post(post) -- invalid repeated declaration
        ;
    ;
    

Loops must mutate a destination-owned aggregate instead of repeatedly declaring an ancestor-owned name.

An ordinary declaration's binding visibility follows its destination group. Visibility begins at the declaration point, not at the group opening. Name collisions are checked in the destination group scope. Ordinary declarations without into retain normal lexical visibility.

Placement eligibility

Define:

A declaration may be placed into a group when its result root is fresh and every retained edge is legal for the destination group, or when it is an independent result graph such as an explicit copy. An alias result cannot become new group-owned storage through into.

A fresh result root may retain parameters or other pre-existing values only when every retained edge satisfies the destination lifetime's outlives constraints. An independent result graph can enter an unrelated destination lifetime without retained-edge constraints to its source graph. A WIT value-only result is an independent result graph, not merely a fresh result root. An explicit copy result is an independent result graph while preserving internal alias topology inside the copied graph.

Nested groups and retained edges

Nested groups are valid.


    group request:
        config Config into request = load_config()

        group scratch:
            parsed ParsedPost into scratch = parse_post(post)
            html String into request = render_post(parsed, config)
        ;

        use(html)
    ;
    

For a child group nested in a parent group:

| Retained edge | Rule | |---|---| | child value -> parent value | valid | | child value -> same-child value | valid | | parent value -> child value | invalid | | sibling value -> sibling-group value | invalid | | child value -> unrelated shorter-lived value | invalid |

A group-owned aggregate may retain:

It must not retain values owned by a child, sibling or otherwise shorter-lived region. Parent, sibling or otherwise longer-lived regions must not retain child-group values.

A group has one lexical entry and explicit exits. Values placed into the group cannot escape it. The compiler must not silently widen a declared group. A child group ends before its parent.

No group-to-group transfer in V1

A value crosses from a shorter-lived group into a longer-lived group only by producing independent storage in the destination lifetime or by invoking a fresh producer that allocates directly into the destination. V1 has no extraction, adoption, or group-to-group transfer operation.

Use one of:

This avoids graph splitting, alias rewriting and drop-list reparenting.

Escapes

Lifetime-region and escape validation must reject every path where a group-owned value or alias can outlive the group.

Invalid escapes include:

Backend GC representation does not legalise these cases.

Crossing a group boundary


    group request:
        group scratch:
            label String into scratch = [: temporary]
            saved String into request = copy label
        ;

        use(saved)
    ;
    

A fresh producer may avoid the copy by allocating directly into the destination group.

Interior aliases stay rooted

An interior alias rooted in group-owned storage cannot escape its group by itself.


    load_name |id String| -> String, Error!:
        group scratch:
            user User into scratch = load_user(id)!
            return user.name -- invalid
        ;
    ;
    

Use copy or produce a fresh value directly in a longer-lived group. Interior projections remain rooted in their containing allocation family; see the lifetime-regions leaf for the general projection rule.

Reassignment inside a group

V1 has no into group syntax on reassignment. A mutable binding already owned by a group may be reassigned only with a fresh result root valid for that same group, an independent copy allocated into that group, or a same-group value transferred at a proven final use.

A mutable binding declared into a group remains group-owned. Reassignment does not let a group-owned binding switch into a borrowed alias of ancestor, sibling or external storage. Use a separate ordinary alias binding for that purpose.

Calls and hidden result destinations

Group-owned values passed to ordinary functions are normally passed as borrowed. The callee may read or mutate according to the source access contract, but it must not individually destroy the group-owned allocation.

Functions whose result root is fresh may allocate that root directly into a caller-selected destination. The function summary classifies the result root as fresh and records retained-edge constraints separately.

A hidden destination:

A fresh-result-root summary and its retained-edge constraints must be backend-neutral parts of the function's semantic effect information.

The ownership bit records destruction responsibility only. It does not prove uniqueness, identify the final observer or establish lifetime legality. A group owns release responsibility for its storage family; an individual callee cannot release one group child merely because an owned bit exists.

Reactive and builder-owned lifetimes

Reactive storage can outlive the lexical function that creates it. V1 must reject:

A GC backend may use ordinary reachability for already-legal topology.

A future collector-free HTML-Wasm path should use builder-owned lifecycle regions such as:


        Page region
        ├── page reactive state
        ├── Mount A region
        │   ├── subscriptions
        │   └── render-generation region
        └── Mount B region
            ├── subscriptions
            └── render-generation region
    

Builder-owned regions obey the same retained-edge rule as source groups, but they are created and ended by the project runtime rather than ordinary source blocks. Builders cannot change source legality.

Diagnostics

Group-specific diagnostics must identify:

Remedy order remains:

  1. allocate directly into the destination group;
  2. place observers under one common group;
  3. use copy;
  4. shorten the retained edge;
  5. repair package-owned metadata where applicable.

User-authored topology that is invalid or cannot be proven legal produces a structured CompilerDiagnostic. Missing or inconsistent compiler-owned summaries, boundary classifications, lifecycle roots, or metadata produce CompilerError.

There is no backend-specific escape from semantic lifetime diagnostics. A backend may ignore physical grouping through GC while preserving the source contract.

Deferred extensions

Deferred work includes:

Final-use extraction of one projected child from an allocation family is not accepted current design and is not part of the first grouped-memory implementation. See the deferred child-extraction note in docs/roadmap/plans/grouped-memory-design.md.

Related reading