import copy
import enum
import threading
from datetime import datetime, timezone
from typing import Dict, List, Optional, ClassVar
from melder.mutation_research.research_set.grouped_research_node import (
GroupedResearchNode,
)
from melder.mutation_research.research_set.research_node import ResearchNode
from melder.utilities.general_base.cleanable import Cleanable
from melder.utilities.helpers.id_builder import IDBuilder
def node_identity(node: object) -> str:
"""
Return one node's identity across BOTH node families.
Purpose:
The single dispatch point for heterogeneous lanes (owner ruling
2026-07-11: GroupedResearchNode is its own type; the carrying code
extends): spell nodes identify by binding-signature `spell_id`,
composition nodes by content-addressed `group_id`. Both live in
the same sha namespace, so lane indexing, residence, and journal
endpoints carry either without shape changes.
Args:
node:
ResearchNode or GroupedResearchNode.
Returns:
str:
The node's identity sha.
Raises:
TypeError:
If the object is neither node family (the error names both).
"""
if isinstance(node, GroupedResearchNode):
return node.group_id
if isinstance(node, ResearchNode):
return node.spell_id
raise TypeError(
"node must be a ResearchNode or a GroupedResearchNode; got "
f"{type(node).__name__}."
)
[docs]
class LaneState(enum.Enum):
"""
Lifecycle states for one research lane.
Registration:
VALUE VOCABULARY. An enum is a value a caller
passes and compares, never an object Melder injects, so there is nothing
to refuse at bind time.
Subsystem Context:
The lifecycle half of the lane vocabulary, beside `LaneType` (the policy
half). State says whether a lane still accepts work; type says what kind
of work it was for. They are deliberately orthogonal - an archived
production lane and an open production lane differ in state, not type.
Subsystem Context Note - both exits are terminal:
`joined` and `archived` both end a lane's writable life, and neither is
undone in place. Recovery runs through `NetworkVersioner`, which rebuilds
containers wholesale rather than reopening them.
System Context:
Lane state is organization, not runtime. A lane changing state has ZERO
effect on what is live - runtime residency is an orthogonal question
answered by the frame, which is why archiving a lane never disturbs a
running world.
Contract:
- `open`: the lane accepts registrations and organization.
- `joined`: the lane finished into its parent; archived-equivalent,
no further work happens FROM this container (its objects live on;
new lanes may still anchor at any node that remains here).
- `archived`: the lane left the active view as a dead end; the
organization snapshot machinery can restore views that contained
it, and residence stays permanent so rediscovery still points here.
AGENT_ACCESS: public
AGENT_PURPOSE:
access: public. Lifecycle state of one research lane. Read it from lane views;
MutationResearch verbs move it.
"""
open = "open"
joined = "joined"
archived = "archived"
[docs]
class LaneType(enum.Enum):
"""
Policy vocabulary for one research lane (salvaged May classification).
Registration:
VALUE VOCABULARY. Users pass this in directly
(`research_create_lane(lane_type=...)`), so it is a value, not an
injectable object.
Subsystem Context:
The policy half of the lane vocabulary, beside `LaneState` (the
lifecycle half). Deliberately decoupled from lane NAMES: names stay
freeform for humans, the type is the word policy reads.
System Context:
Note how little this gates. The type never restricts registration or
reads - its ONLY hook is the join gate, and only when the set's
`lane_type_enforcement` posture is on (off by default). That restraint
is the point: this is a research tool, so classification exists to
inform an agent rather than to block it, and even the one gate it does
have yields to the same `force=True` supersede the divergence law uses.
Contract:
- The type is the POLICY word; lane names stay freeform.
- Vocabulary: `development` (the trunk posture; the guaranteed
default lane), `experiment` (the default for freeform lanes -
this is a research tool), `production` (a lane whose tip is
runtime-promotion-worthy), `test` (throwaway validation work).
- The type never gates registration or reads; the ONLY policy hook
is the join gate, and only while the set's lane-type enforcement
posture is on (configuration `lane_type_enforcement`, default
off) - a type-mixing join then requires the same force=True
supersede the divergence law already uses.
AGENT_ACCESS: public
AGENT_PURPOSE:
access: public. Lane classification - development, experiment, production, test. Pass to
create_lane(...). Cross-type joins require force=True when configuration
lane_type_enforcement is on.
"""
development = "development"
experiment = "experiment"
production = "production"
test = "test"
[docs]
class ResearchLane(Cleanable):
"""
Governance (single-residence law, BUG-048):
Lanes are handed out LIVE as read surfaces. Every mutator on this
class is set-internal (underscore-prefixed): residence claims, the
journal, snapshots, and persistence emission all live on the owning
`ResearchSet`, so public state change flows through set verbs ONLY.
Public callers read; the owning set writes.
One object's line of versions inside the research network.
Purpose:
Hold the ordered, full-object version records that describe how one
conceptual object moved through candidate futures. A lane is a graph
container, not a runtime surface: lane membership has ZERO runtime
footprint (versions live in crystallizer custody; runtime residency
is an orthogonal, on-demand act).
Contract:
- Nodes are full-object records keyed by binding-signature SHA256;
diffs are a derived read feature, never storage.
- One node per SHA per lane; the set-level `ResidenceRegistry`
guarantees one lane per SHA network-wide.
- The anchor (`anchor_lane_id` + `anchor_spell_id`) organizes ancestry
onto another lane's node; it never moves content.
- State machine: open -> joined | archived; both exits are terminal
for this container (recovery happens via network restore, which
rebuilds containers wholesale).
Registration:
MELDER KERNEL - guarded. Lanes are handed out live as READ surfaces and
written only by their owning set; a user never constructs or registers
one.
Subsystem Context:
The container tier of the ResearchSet package: `ResearchSet` owns lanes,
lanes hold nodes, and nodes reference custody. It accepts BOTH node
families - `ResearchNode` (one object's versions) and
`GroupedResearchNode` (one subsystem's compositions) - dispatching
through the module-level identity helper, so a lane of group nodes is a
subsystem's timeline exactly as a lane of spell nodes is an object's.
System Context:
A lane is a graph container with ZERO runtime footprint. Membership does
not make anything live, and promotion does not move anything between
lanes - "which lane holds this version" and "which version is currently
selected" are deliberately independent questions. That separation is
what lets research organization be reorganized freely without ever
disturbing a running world.
- Mutating verbs require the open state and raise otherwise.
- `describe()` / `from_payload()` are exact inverses (nodes ride
nested payloads).
Threading:
Instance `RLock` serializes node/anchor/state mutation and reads.
Lifecycle:
Owned by exactly one `ResearchSet`; `cleanup()` cleans owned nodes
then deletes owned fields; idempotent; lock released last.
AGENT_ACCESS: internal
AGENT_PURPOSE:
access: internal. Governance (single-residence law, BUG-048): Lanes are handed out LIVE
as read surfaces. Every mutator on this class is set-internal (underscore-prefixed):
residence claims, the journal, snapshots, and persistence emission all live on the
owning `ResearchSet`, so public state change flows through set verbs ONLY. Melder kernel
machinery: read it to understand the runtime, do not drive it directly.
"""
__slots__ = Cleanable.__slots__ + [
"_lane_id",
"_name",
"_lane_type",
"_anchor_lane_id",
"_anchor_spell_id",
"_nodes_by_spell_id",
"_node_order",
"_tip_spell_id",
"_state",
"_joined_into_lane_id",
"_created_at",
"_metadata",
"_lock",
]
def __init__(
self,
name: str,
*,
lane_type: Optional[str] = None,
lane_id: Optional[str] = None,
created_at: Optional[str] = None,
metadata: Optional[Dict[str, object]] = None,
) -> None:
"""
Initialize one open, empty research lane.
Contract:
- BORN OPEN AND EMPTY, with no tip and no anchor. State advances
open -> joined | archived one-way and never returns to open.
- `lane_type` defaults to `experiment`, NOT to the owning set's
default-lane type of `development`. A freshly created lane is an
experiment until the caller says otherwise; the set passes
`development` explicitly only for its guaranteed default lane.
- An UNKNOWN `lane_type` raises `ValueError` naming the valid
vocabulary, so a typo cannot silently create an untyped lane.
- `lane_id` is minted as a fresh ULID unless one is supplied. A
supplied id is the RESTORE path - `from_payload` passes the
recorded id so a rebuilt lane keeps its identity - and is trusted
without a uniqueness check here, because uniqueness is the owning
set's responsibility.
- `metadata` is deep-copied in, so the caller's dict cannot mutate
the lane afterwards. `created_at` is minted now only when omitted.
- Name UNIQUENESS is not enforced here; the owning set indexes lanes
by name and owns that guarantee. This constructor only rejects an
empty name.
Threading:
Creates the lane's own `RLock`; construction is otherwise
unsynchronized because the object is not yet shared.
Args:
name:
Human-facing lane name (uniqueness is enforced by the owning
set, which indexes lanes by name).
lane_type:
Optional policy vocabulary word (`LaneType` value). Defaults
to `experiment` (this is a research tool); the owning set
passes `development` for the guaranteed default lane.
lane_id:
Optional stable id (restore path); a fresh ULID is minted
when omitted.
created_at:
Optional ISO-8601 UTC stamp; minted now when omitted.
metadata:
Optional value-typed annotations (detached copy is stored).
Raises:
ValueError:
If name is empty, or lane_type is not a `LaneType` value
(the error names the vocabulary).
Returns:
None.
"""
super().__init__()
if not isinstance(name, str) or not name:
raise ValueError("name must be a non-empty string.")
self._lane_id: str = lane_id if lane_id else IDBuilder.create_id()
self._name: str = name
if lane_type is None:
self._lane_type: LaneType = LaneType.experiment
else:
try:
self._lane_type = LaneType(lane_type)
except ValueError:
known = [member.value for member in LaneType]
raise ValueError(
f"Unknown lane_type '{lane_type}'. Known types: "
f"{known}."
) from None
self._anchor_lane_id: Optional[str] = None
self._anchor_spell_id: Optional[str] = None
self._nodes_by_spell_id: Dict[str, ResearchNode] = {}
self._node_order: List[str] = []
self._tip_spell_id: Optional[str] = None
self._state: LaneState = LaneState.open
self._joined_into_lane_id: Optional[str] = None
self._created_at: str = (
created_at
if created_at
else datetime.now(timezone.utc).isoformat()
)
self._metadata: Dict[str, object] = copy.deepcopy(metadata) if metadata else {}
self._lock: threading.RLock = threading.RLock()
[docs]
def cleanup(self) -> None:
"""
Clean owned nodes, release fields, and mark the lane cleaned.
Contract:
- IDEMPOTENT under double-checked locking: `_cleaned` is tested
before and inside the lock.
- OWNS ITS NODES: every held `ResearchNode` is cleaned (best-effort,
so one failing node cannot strand the rest) before the lane's own
fields are dropped. A node's single-residence lane is the thing
that cleans it.
- DELETE-NOT-NULL posture, no tombstones; post-cleanup access raises
`AttributeError` via `check_cleaned()`.
- The lock is deleted LAST, outside the guarded block.
Returns:
None.
"""
if self._cleaned:
return
with self._lock:
if self._cleaned:
return
self._cleaned = True
for node in self._nodes_by_spell_id.values():
try:
node.cleanup()
except Exception:
pass
self._nodes_by_spell_id.clear()
self._node_order.clear()
del self._nodes_by_spell_id
del self._node_order
del self._tip_spell_id
del self._anchor_lane_id
del self._anchor_spell_id
del self._state
del self._joined_into_lane_id
del self._metadata
del self._created_at
del self._lane_type
del self._name
del self._lane_id
del self._lock
def _require_open(self) -> None:
"""
Raise unless the lane is in the open state.
Raises:
RuntimeError:
If the lane is joined or archived.
"""
if self._state is not LaneState.open:
raise RuntimeError(
f"Lane '{self._name}' ({self._lane_id}) is "
f"{self._state.value}; no further work happens from this "
f"container."
)
@property
def lane_id(self) -> str:
"""
Return the stable lane id (ULID).
Contract:
- Machine identity, fixed at construction and unchanged for the
lane's life; distinct from `name`, which is the human-facing key.
Survives a describe/from_payload round trip.
Returns:
str:
Lane id.
"""
self.check_cleaned()
return self._lane_id
@property
def name(self) -> str:
"""
Return the human-facing lane name.
Contract:
- The lookup key the owning set indexes by; unique within that set.
Distinct from `lane_id`, which is the stable machine identity.
Returns:
str:
Lane name.
"""
self.check_cleaned()
return self._name
@property
def lane_type(self) -> LaneType:
"""
Return the policy vocabulary word for this lane.
Contract:
- The TYPE is the policy word; the name is freeform. The only place
type is enforced is the set's join gate (`lane_type_enforcement`),
where a type-mixing join needs `force=True`. Reading it here is
always allowed and never gated.
Returns:
LaneType:
development, experiment, production, or test.
"""
self.check_cleaned()
return self._lane_type
@property
def state(self) -> LaneState:
"""
Return the current lifecycle state.
Contract:
- ONE-WAY STATE MACHINE: `open -> joined` or `open -> archived`, and
never back. Only an `open` lane accepts new work; `joined` and
`archived` are terminal read-only containers.
- `joined` and `archived` are distinct terminals: `joined` means the
lane's line was folded into a receiver (see `joined_into_lane_id`);
`archived` means it was retired in place. Neither loses its held
nodes - the records stay readable.
Returns:
LaneState:
open, joined, or archived.
"""
self.check_cleaned()
with self._lock:
return self._state
@property
def tip_spell_id(self) -> Optional[str]:
"""
Return the newest registered identity in this lane, when any.
Contract:
- The tip is the LAST entry in registration order, so it tracks the
node line, not ancestry. It is `None` only while the lane is empty.
- It MOVES BACKWARD on detach: when a join transfers the tail nodes
out, the tip becomes the last remaining node (or `None` if all
were taken), so it is not a monotonic high-water mark.
Returns:
Optional[str]:
Tip SHA256 or None while empty.
"""
self.check_cleaned()
with self._lock:
return self._tip_spell_id
@property
def anchor_lane_id(self) -> Optional[str]:
"""
Return the lane this lane anchors onto, when attached.
Contract:
- Ancestry is a SINGLE pointer, not a list: a lane anchors onto at
most one node in one other lane. `None` means this lane is a root
of its own line.
- Always moves in lockstep with `anchor_spell_id` - they are set
together and cleared together, so one being `None` implies the
other is too.
Returns:
Optional[str]:
Anchor lane id or None.
"""
self.check_cleaned()
with self._lock:
return self._anchor_lane_id
@property
def anchor_spell_id(self) -> Optional[str]:
"""
Return the node identity this lane anchors at, when attached.
Contract:
- The specific node within `anchor_lane_id` this lane hangs from.
`None` exactly when `anchor_lane_id` is `None`; the pair is set and
cleared atomically.
- Names a node in the OTHER lane, not in this one - it is the
ancestry attach point, not one of this lane's own members.
Returns:
Optional[str]:
Anchor SHA256 or None.
"""
self.check_cleaned()
with self._lock:
return self._anchor_spell_id
@property
def joined_into_lane_id(self) -> Optional[str]:
"""
Return the receiving lane id after a join, when joined.
Contract:
- `None` UNTIL A JOIN, and set exactly once when the lane transitions
to `joined`. It is the forwarding pointer that says where this
lane's line went.
- Independent of the ANCHOR pointer: anchoring is ancestry
organization on an open lane, joining is a terminal handoff. A lane
can be anchored without being joined and vice versa.
Returns:
Optional[str]:
Receiving lane id or None.
"""
self.check_cleaned()
with self._lock:
return self._joined_into_lane_id
@property
def node_count(self) -> int:
"""
Return the number of version records held by this lane.
Contract:
- Counts nodes CURRENTLY held, so it drops when a join detaches the
tail out. It is not a lifetime total of everything ever registered
here - the journal holds that history.
Returns:
int:
Node count.
"""
self.check_cleaned()
with self._lock:
return len(self._node_order)
@property
def created_at(self) -> str:
"""
Return the ISO-8601 UTC creation stamp.
Contract:
- Always present. On a rebuilt lane it is the ORIGINAL recorded time,
not the rebuild time, because `from_payload` passes the stored
stamp through.
Returns:
str:
Creation timestamp.
"""
self.check_cleaned()
return self._created_at
def _add_node(self, node: object) -> None:
"""
Append one record (either node family) and advance the tip.
Args:
node:
Immutable ResearchNode (version record) or
GroupedResearchNode (composition record) to hold.
Raises:
RuntimeError:
If the lane is not open.
TypeError:
If the object is neither node family.
ValueError:
If the identity is already held by this lane (full-object
records dedup by content SHA - for compositions, an
identical member set IS the same identity).
"""
self.check_cleaned()
identity = node_identity(node)
with self._lock:
self._require_open()
if identity in self._nodes_by_spell_id:
raise ValueError(
f"Lane '{self._name}' already holds identity "
f"'{identity}'."
)
self._nodes_by_spell_id[identity] = node
self._node_order.append(identity)
self._tip_spell_id = identity
[docs]
def get_node(self, spell_id: str) -> ResearchNode:
"""
Return the version record for one held identity.
Contract:
- Returns the LIVE node object, not a copy. The node is immutable, so
sharing it is safe, but it is the same object the lane holds.
- A non-held identity raises `KeyError` naming the lane, so the
return is never `None` and needs no null check. Use `has_node` to
test membership without catching.
Threading:
Lookup runs under `self._lock`.
Args:
spell_id:
Identity to fetch.
Returns:
ResearchNode:
Held version record.
Raises:
KeyError:
If the identity is not held here.
"""
self.check_cleaned()
with self._lock:
node = self._nodes_by_spell_id.get(spell_id)
if node is None:
raise KeyError(
f"Lane '{self._name}' holds no identity '{spell_id}'."
)
return node
[docs]
def has_node(self, spell_id: str) -> bool:
"""
Return whether this lane holds one identity.
Contract:
- Tests CURRENT membership of THIS lane only. It says nothing about
whether the identity resides elsewhere in the network - that is the
set's residence registry. False here plus a residence answer
elsewhere is normal after a join moved the node.
Threading:
Membership test runs under `self._lock`.
Args:
spell_id:
Identity to test.
Returns:
bool:
True when held.
"""
self.check_cleaned()
with self._lock:
return spell_id in self._nodes_by_spell_id
[docs]
def node_spell_ids(self) -> List[str]:
"""
Return the held identities in registration order.
Contract:
- A FRESH list, so mutating it cannot alter the lane's order.
- REGISTRATION ORDER, oldest first; the last element is the tip.
- Ids only. Use `nodes()` when the records are needed - it reads the
same order without a second lookup.
Threading:
Materialized under `self._lock`; a coherent snapshot.
Returns:
List[str]:
Detached ordered identity list.
"""
self.check_cleaned()
with self._lock:
return list(self._node_order)
[docs]
def nodes(self) -> List[ResearchNode]:
"""
Return the held version records in registration order.
Contract:
- A FRESH list of the LIVE node objects, registration order, oldest
first. The list is detached (safe to mutate); the nodes inside it
are immutable and shared.
- Positionally aligned with `node_spell_ids()` for the same lane
state, since both walk the same order.
Threading:
Built under `self._lock`; a coherent snapshot of the current line.
Returns:
List[ResearchNode]:
Detached ordered node list (nodes are immutable).
"""
self.check_cleaned()
with self._lock:
return [self._nodes_by_spell_id[sha] for sha in self._node_order]
def _detach_nodes(self, spell_ids: List[str]) -> List[ResearchNode]:
"""
Remove and return the given records in registration order.
Purpose:
The join transfer mechanic: the receiving lane absorbs these
records; this container stops holding them.
Args:
spell_ids:
Identities to detach; every one must be held here.
Returns:
List[ResearchNode]:
Detached records in this lane's registration order.
Raises:
RuntimeError:
If the lane is not open.
KeyError:
If any identity is not held (nothing is detached then).
"""
self.check_cleaned()
with self._lock:
self._require_open()
requested = set(spell_ids)
for spell_id in requested:
if spell_id not in self._nodes_by_spell_id:
raise KeyError(
f"Lane '{self._name}' holds no identity "
f"'{spell_id}'."
)
detached: List[ResearchNode] = []
remaining_order: List[str] = []
for spell_id in self._node_order:
if spell_id in requested:
detached.append(self._nodes_by_spell_id.pop(spell_id))
else:
remaining_order.append(spell_id)
self._node_order = remaining_order
self._tip_spell_id = remaining_order[-1] if remaining_order else None
return detached
def _set_anchor(self, anchor_lane_id: str, anchor_spell_id: str) -> None:
"""
Attach this lane's ancestry onto another lane's node.
Args:
anchor_lane_id:
Lane being anchored onto.
anchor_spell_id:
Node identity within that lane to anchor at.
Raises:
RuntimeError:
If the lane is not open.
ValueError:
If either argument is empty.
"""
self.check_cleaned()
if not isinstance(anchor_lane_id, str) or not anchor_lane_id:
raise ValueError("anchor_lane_id must be a non-empty string.")
if not isinstance(anchor_spell_id, str) or not anchor_spell_id:
raise ValueError("anchor_spell_id must be a non-empty string.")
with self._lock:
self._require_open()
self._anchor_lane_id = anchor_lane_id
self._anchor_spell_id = anchor_spell_id
[docs]
def clear_anchor(self) -> None:
"""
Detach this lane's ancestry anchor.
Contract:
- OPEN LANES ONLY. A joined or archived lane raises, because ancestry
is not reorganized on a terminal container.
- NOT IDEMPOTENT: clearing a lane that holds no anchor raises rather
than returning quietly, so a redundant clear is a caller error.
- Clears BOTH anchor fields together, restoring the lane to a root of
its own line. Content is untouched - only the ancestry pointer
goes.
- This is a set-internal effect at the lane level; the public
`ResearchSet.detach` verb is the journalled path. Clearing here
alone does not write a journal entry.
Threading:
The open-check and the clear run under `self._lock`.
Raises:
RuntimeError:
If the lane is not open, or when no anchor exists.
Returns:
None.
"""
self.check_cleaned()
with self._lock:
self._require_open()
if self._anchor_lane_id is None:
raise RuntimeError(
f"Lane '{self._name}' has no anchor to detach."
)
self._anchor_lane_id = None
self._anchor_spell_id = None
def _mark_joined(self, into_lane_id: str) -> None:
"""
Finish this lane into a receiving lane (terminal).
Args:
into_lane_id:
Receiving lane id.
Raises:
RuntimeError:
If the lane is not open.
ValueError:
If into_lane_id is empty.
"""
self.check_cleaned()
if not isinstance(into_lane_id, str) or not into_lane_id:
raise ValueError("into_lane_id must be a non-empty string.")
with self._lock:
self._require_open()
self._state = LaneState.joined
self._joined_into_lane_id = into_lane_id
def _mark_archived(self) -> None:
"""
Archive this lane as a dead end (terminal for this container).
Raises:
RuntimeError:
If the lane is not open.
"""
self.check_cleaned()
with self._lock:
self._require_open()
self._state = LaneState.archived
[docs]
def describe(self) -> Dict[str, object]:
"""
Return a detached, serialization-ready snapshot of this lane.
Contract:
- THE EXACT INVERSE of `from_payload()`, capturing the FULL lifecycle
state - state, both anchor fields, tip, joined-into pointer - not
just contents, so a joined or archived lane round-trips as joined
or archived rather than reviving open.
- NODES RIDE NESTED describe() PAYLOADS in registration order, so the
whole node line is embedded; the lane's order and tip are
reconstructible from `nodes` alone.
- Enum fields are emitted as their `.value` strings and `metadata` is
deep-copied, so the payload is JSON-safe and fully detached.
Threading:
Assembled under `self._lock`, so lifecycle fields and the node line
are mutually consistent.
Returns:
Dict[str, object]:
Plain-value payload (exact `from_payload()` inverse; nodes
ride nested `describe()` payloads in registration order).
"""
self.check_cleaned()
with self._lock:
return {
"lane_id": self._lane_id,
"name": self._name,
"lane_type": self._lane_type.value,
"state": self._state.value,
"anchor_lane_id": self._anchor_lane_id,
"anchor_spell_id": self._anchor_spell_id,
"tip_spell_id": self._tip_spell_id,
"joined_into_lane_id": self._joined_into_lane_id,
"created_at": self._created_at,
"metadata": copy.deepcopy(self._metadata),
"nodes": [
self._nodes_by_spell_id[sha].describe()
for sha in self._node_order
],
}
[docs]
@classmethod
def from_payload(cls, payload: Dict[str, object]) -> "ResearchLane":
"""
Rebuild one lane from a `describe()` payload.
Contract:
- `name`, `lane_id`, and a `nodes` LIST are the hard requirements;
their absence or wrong type raises `ValueError`. Everything else
degrades to a default.
- NODE-FAMILY DISPATCH per entry: a payload tagged with the grouped
node type hydrates as a `GroupedResearchNode`, an untagged one as a
`ResearchNode`. Back-compat is by ABSENCE - pre-grouping payloads
have no tag and correctly rebuild as spell nodes.
- LANE-TYPE BACK-COMPAT: a payload sealed before the type vocabulary
carries no `lane_type`, and hydrates as `development` when its name
is `default`, `experiment` otherwise - mirroring how a fresh lane
of each kind is typed.
- PRESERVES recorded identity and time: the stored `lane_id` and
`created_at` are passed through, so a rebuilt lane is the same
identity it was sealed as.
- The tip is recomputed as the last node in the rebuilt order rather
than trusted from the payload, so order and tip cannot disagree.
- Runs each node's own `from_payload`, so a corrupt node payload is
rejected by that node's constructor - the lane cannot rebuild a
node its own type would refuse.
Threading:
The rebuilt lane is not shared until this returns; nodes are loaded
under the new lane's lock.
Args:
payload:
Detached payload produced by `describe()`.
Returns:
ResearchLane:
Reconstructed lane (state, anchor, order, and tip restored).
Raises:
ValueError:
If the payload shape is invalid.
"""
if not isinstance(payload, dict):
raise ValueError("payload must be a dict produced by describe().")
name = payload.get("name")
lane_id = payload.get("lane_id")
if not isinstance(name, str) or not isinstance(lane_id, str):
raise ValueError("payload is missing 'name'/'lane_id' values.")
metadata = payload.get("metadata")
lane_type = payload.get("lane_type")
if not isinstance(lane_type, str):
# Back-compat: payloads sealed before the type vocabulary carry
# no lane_type. The guaranteed default lane hydrates as the
# trunk posture; every other lane hydrates as research work.
lane_type = (
LaneType.development.value
if name == "default"
else LaneType.experiment.value
)
lane = cls(
name,
lane_type=lane_type,
lane_id=lane_id,
created_at=payload.get("created_at"),
metadata=metadata if isinstance(metadata, dict) else None,
)
node_payloads = payload.get("nodes")
if not isinstance(node_payloads, list):
raise ValueError("payload is missing a valid 'nodes' list.")
with lane._lock:
for node_payload in node_payloads:
# Node-family dispatch (owner ruling 2026-07-11): tagged
# payloads hydrate as compositions; untagged payloads are
# spell nodes - back-compat by absence.
if (
isinstance(node_payload, dict)
and node_payload.get("node_type")
== GroupedResearchNode.NODE_TYPE
):
node = GroupedResearchNode.from_payload(node_payload)
else:
node = ResearchNode.from_payload(node_payload)
identity = node_identity(node)
lane._nodes_by_spell_id[identity] = node
lane._node_order.append(identity)
lane._tip_spell_id = (
lane._node_order[-1] if lane._node_order else None
)
anchor_lane_id = payload.get("anchor_lane_id")
anchor_spell_id = payload.get("anchor_spell_id")
if isinstance(anchor_lane_id, str) and isinstance(anchor_spell_id, str):
lane._anchor_lane_id = anchor_lane_id
lane._anchor_spell_id = anchor_spell_id
state_value = payload.get("state")
if isinstance(state_value, str):
lane._state = LaneState(state_value)
joined_into = payload.get("joined_into_lane_id")
if isinstance(joined_into, str):
lane._joined_into_lane_id = joined_into
return lane