On this page
- ResearchLane
ResearchLaneResearchLane.cleanup()ResearchLane.lane_idResearchLane.nameResearchLane.lane_typeResearchLane.stateResearchLane.tip_spell_idResearchLane.anchor_lane_idResearchLane.anchor_spell_idResearchLane.joined_into_lane_idResearchLane.node_countResearchLane.created_atResearchLane.get_node()ResearchLane.has_node()ResearchLane.node_spell_ids()ResearchLane.nodes()ResearchLane.clear_anchor()ResearchLane.describe()ResearchLane.from_payload()
ResearchLane๏
Receive this surface through ResearchSet lane operations; use that owning object's public entry point.
- class ResearchLane(name: str, *, lane_type: str | None = None, lane_id: str | None = None, created_at: str | None = None, metadata: Dict[str, object] | None = None)[source]๏
- 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.
- cleanup() None[source]๏
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.
- property lane_id: 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:
Lane id.
- Return type:
str
- property name: 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:
Lane name.
- Return type:
str
- property lane_type: 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:
development, experiment, production, or test.
- Return type:
- property state: 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:
open, joined, or archived.
- Return type:
- property tip_spell_id: str | None๏
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:
Tip SHA256 or None while empty.
- Return type:
Optional[str]
- property anchor_lane_id: str | None๏
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:
Anchor lane id or None.
- Return type:
Optional[str]
- property anchor_spell_id: str | None๏
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:
Anchor SHA256 or None.
- Return type:
Optional[str]
- property joined_into_lane_id: str | None๏
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:
Receiving lane id or None.
- Return type:
Optional[str]
- property node_count: 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:
Node count.
- Return type:
int
- property created_at: 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:
Creation timestamp.
- Return type:
str
- get_node(spell_id: str) ResearchNode[source]๏
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.
- Parameters:
spell_id -- Identity to fetch.
- Returns:
Held version record.
- Return type:
ResearchNode
- Raises:
KeyError -- If the identity is not held here.
- has_node(spell_id: str) bool[source]๏
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.
- Parameters:
spell_id -- Identity to test.
- Returns:
True when held.
- Return type:
bool
- node_spell_ids() List[str][source]๏
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:
Detached ordered identity list.
- Return type:
List[str]
- nodes() List[ResearchNode][source]๏
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:
Detached ordered node list (nodes are immutable).
- Return type:
List[ResearchNode]
- clear_anchor() None[source]๏
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.
- describe() Dict[str, object][source]๏
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:
Plain-value payload (exact from_payload() inverse; nodes ride nested describe() payloads in registration order).
- Return type:
Dict[str, object]
- classmethod from_payload(payload: Dict[str, object]) ResearchLane[source]๏
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.
- Parameters:
payload -- Detached payload produced by describe().
- Returns:
Reconstructed lane (state, anchor, order, and tip restored).
- Return type:
- Raises:
ValueError -- If the payload shape is invalid.