Skip to content

Forwarding anchors, guest sources, and what a moved-from symbol becomes #152

Description

@TheLazyCat00

Corrected twice. (1) The original claim that a field access rooted in a bare symbol reproduces the five-liner's ambiguity is wrong — see "Correction" below. (2) Option 3 is now struck; it contradicts adt.md. Both are left on the page rather than deleted.

What the goal actually is

Not "avoid merging the two objects" — objects never merge. The destination's occupant is destroyed by the overwrite and the source's object survives at the destination location; that part is ordinary.

The thing to avoid is two live anchor identities resolving to the same payload. A payload carries a single four-byte backpointer, so it can be found from only one cell; a second cell naming it cannot be updated on the next relocation. Forwarding anchors are the workaround for exactly that.

The situation arises only when a move has anchored objects on both sides. An anchored source alone is fine (one cell, rewrite it). An anchored destination alone is fine. Any rule that removes either side is sufficient.

Canonical example

Every option below is described against this program. All four statements are legal under the current spec.

package Demo

type Engine = #struct { power Int; }
type Car    = #struct { engine Engine; }
type Garage = #struct { car Car; }

Engine(power Int) => init{power}
Car(engine Engine) => init{engine}
Garage(car Car) => init{car}

Unit demo() {
    garage Garage(Car(Engine(Int(1))))       // garage.car hosts parkedCar
                                             //   parkedCar.engine hosts parkedEngine
    guestToParked &Engine = garage.car.engine   // mints parkedAnchor, naming parkedEngine

    {
        arriving Car(Engine(Int(2)))         // arriving hosts a Car
                                             //   its .engine hosts arrivingEngine
        guestToArriving &Engine = arriving.engine   // mints arrivingAnchor,
                                                    //   naming arrivingEngine

        garage.car = arriving                // ← the move
    }

    return Unit()
}

Naming: parked* is everything already in the garage — the destination side. arriving* is everything in the local being moved in — the source side.

What the move does:

  1. The parked Car is destroyed, and parkedEngine with it.
  2. The arriving Car lands in garage.car, so arrivingEngine is now at garage.car.engine.
  3. guestToParked follows the slot (§2.2), so it must now observe arrivingEngine.
  4. guestToArriving follows the object, so it also observes arrivingEngine.

Both parkedAnchor and arrivingAnchor now resolve to one payload, which has one backpointer. That is the merge, and it is why forwarding exists: arrivingAnchor becomes a forwarding cell targeting parkedAnchor, which stays terminal and owns the backpointer.

option effect on the example where the error lands
current merge; arrivingAnchor forwards to parkedAnchor legal
1 declare arriving must be transit to be moved, so guestToArriving is rejected at the guest
2 infer guestToArriving pins arriving, so garage.car = arriving is rejected at the move
3 structure both guests rejected struck — see below
4 siblings merge allowed; the two anchors are ring-linked, both terminal legal
5 accept merge allowed; unchanged from current legal

Options 1 and 2 differ mainly in where you find out: option 1 forces the choice at the declaration, option 2 lets both be written and rejects the second.

Correction: there is no ambiguity gap

The original version of this issue claimed these lines reproduce the problem memory.md §2.8.1 solves:

g &Item = box.item
other = box

They do not. lifetimes.md §1.8 makes a moved-from symbol downgrade to a guest to the same object rather than becoming empty, so box.item still denotes the same field afterwards and both readings coincide.

A field cannot be moved out of (§1.2) and travels with its object when rehosted, so a field access denotes the same storage regardless of what happens to the names above it. A bare symbol is the only place where a name and its object can part company, which is what makes §2.8.1 specific rather than an instance of something broader.

1. Declare it

Split local hosts at the declaration site into a stable storage point (may be a guest root, may not be moved) and a package in transit (may be moved or swallowed, may not be pointed at).

  • Open question — polarity. Marking the transit kind puts the mark on the common case and gives bare T a third position-dependent meaning. Marking the stable kind keeps bare T meaning "ordinary, in transit" for locals and parameters alike. Sigil is an open cost; may want a keyword.
  • Objection: building and shipping are not separable activities, and this forces a declaration-time choice between them.

2. Infer it

Minting a guest from a chain rooted at a symbol stops that symbol being a move-source.

Statically checkable without whole-program analysis: §1.3 confines moves of a symbol to its declaration block, and minting from sym.… requires sym in scope. Conservative reading (pinned if minted anywhere in the block) keeps it flow-insensitive.

  • Cost: borrow-checker-shaped — "once pointed at, no longer movable".
  • Cost: visible in APIs. Handing out an & to a field means the owner can never be moved.
  • Recursive structures remain buildable; minting from tree.root merely pins tree.

3. Structure it — struck: contradicts adt.md

The proposal was to require a guest to live strictly below its host's scope, which is the tightening of §1.1 from "the same or a higher lexical scope" to "strictly higher".

It would deliver the guarantee, and — contrary to an earlier draft of this issue — it would cover field-held guests too, since §1.1 already reasons about the scope of the & itself rather than only about local symbols.

It fails for a different reason. Banning an & field from naming a host in its own scope makes recursive types unbuildable: a recursive #variant boxes its recursive case through &, and the child's & field and the host it names are inside the same structure and therefore the same scope. Parent back-pointers and sibling wiring go the same way. This is a contradiction with adt.md, not a cost to weigh.

4. Sibling-linked anchors (runtime only)

Let both anchors stay terminal and link the cells to each other, so a relocation walks the ring and rewrites every member. O(identities merged) per move, always-direct reads, no source-language change.

Against: path compression already makes forwarding reads O(1) in practice, so this pays real move cost for a largely theoretical read cost; and retirement needs unlinking rather than a pop.

5. Accept merging, and make a moved-from symbol spentmaintainer's preferred direction

Keep the merge machinery; simplify the source language instead.

  1. Revert §2.8.1. A bare symbol is a guest source again.
  2. Change §1.8. A moved-from symbol is spent — no longer readable — rather than downgrading to a guest.
  3. Remove 'T. With bare symbols pointable again, the borrow mode's motivating hole is gone.

Why this resolves the original five-liner. The story's argument was that guest &Player = main admits two readings. Making the moved-from symbol spent removes one of them: there is no "watch that variable" once the variable is finished. What remains is a single teachable rule — a guest follows the object; when the host symbol is moved from it is spent, and the guest carries on — with no competing referent.

What it simplifies.

  • Reference-type parameters return to two modes, T and &T.
  • The subject regains a single mode: bare this T is an implicit guest, and this &T becomes redundant.
  • adt.md §4.1 goes; a recursive structure may be rooted in a bare local again, so Expr.flip(leaf) is legal and the one admitted cost of Bare symbols are no longer guest sources; add the 'T borrow mode; rename receiver to subject #151 disappears.
  • §1.8 loses a concept (downgrade) and memory.md §2.4 loses a storage state (a host-capable slot readable in guest state).
  • The retired-forms guard in CLAUDE.md needs '[A-Z] restored and the borrow-exclusion clause in §2.8 removed.

Detail that must be stated explicitly: spent ≠ unassignable. The slot still exists and may be overwritten to host again (§2.2). The relay example at §1.8 survives on that basis, but its prose must change from "downgrades to a guest" to "is spent", and any example that reads a moved-from symbol needs rewriting.

What it costs.

  • Merging stays reachable, so forwarding anchors, retirement stacks and the target-kind discriminator are permanent. Options 1 and 2 become moot.
  • The value/reference subject unification is lost — a mut subject is a mutable borrow for values and a guest for references again, the asymmetry Bare symbols are no longer guest sources; add the 'T borrow mode; rename receiver to subject #151 removed and the story calls "the pleasing part".
  • Signatures lose the non-escaping promise. With only &T, a caller cannot tell whether a callee retains a reference. Still safe under §1.1, but less legible. Worth considering keeping 'T for this reason alone, independent of the hole it was created to fill.

Rationale for preferring it: the merge machinery is implemented once by the compiler author; the §2.8.1 restriction is paid by every program that builds a structure and hands out a reference to it.

Disposition

With option 3 struck, the real choice is 1 or 2 — prevent the merge, delete the machinery, pay in language restriction — versus 5, accept the merge, keep the machinery, simpler language. Option 4 is a runtime-only variation of 5.

Nothing here is a defect in #151, which is merged. Option 5 reverts most of its language changes while keeping the anchor and arena work inherited from #147, and keeping the receiversubject rename, which is independent.

Reverting a merged rule needs its own chapter in stories/memory.md saying plainly that the ban was the wrong instrument and why. "The slot that could not be pointed at" stays as written; the new chapter names its claim and supersedes it. naming-terms.md §6 also puts the rejected candidates above on the record.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions