On this page
- ResearchSet
ResearchSetResearchSet.cleanup()ResearchSet.lane_type_enforcementResearchSet.set_lane_type_enforcement()ResearchSet.set_idResearchSet.nameResearchSet.default_laneResearchSet.journalResearchSet.get_lane()ResearchSet.lane_names()ResearchSet.residence_of()ResearchSet.heads()ResearchSet.walk()ResearchSet.history()ResearchSet.campaign_view()ResearchSet.create_lane()ResearchSet.register_spell()ResearchSet.register_group()ResearchSet.recompose_group()ResearchSet.group_history()ResearchSet.record_world_entry()ResearchSet.record_promotion()ResearchSet.attach()ResearchSet.detach()ResearchSet.join()ResearchSet.archive()ResearchSet.snapshot_network()ResearchSet.network_snapshot_shas()ResearchSet.latest_network_snapshotResearchSet.restore_network()ResearchSet.describe_composition()ResearchSet.describe()ResearchSet.from_payload()
ResearchSet๏
Use md.ResearchSet from the public package namespace.
- class ResearchSet(name: str = 'default', *, max_network_snapshots: int = 64, on_mutation: Callable[[], None] | None = None)[source]๏
The overarching research network: every lane, one journal, one partition.
- Purpose:
The single agent-facing surface of MutationResearch. Agents formally declare bound versions as research (register_spell), organize lanes (create_lane, attach, detach), finish lines (join), retire dead ends (archive), and read the record (walk, history, heads). The organization itself is version-controlled (snapshot_network / restore_network) because objects are indestructible - only their arrangement is at risk.
Contract:
SINGLE RESIDENCE: one binding-signature SHA256 lives in exactly ONE lane network-wide, permanently (through archive). Identical content rebinds to the same SHA and surfaces as a rediscovery error naming the holding lane.
Forward-only history: every verb journals a world-entry event; the journal is append-only and SURVIVES network restore.
A guaranteed default lane exists from birth; register_spell without an explicit lane records there (no orphan binds, no history holes).
Every mutating verb re-snapshots the organization and fires the injected on_mutation callback (the persistence emission seam) - this object itself never touches the crystallizer (dependency rule: only the MutationResearch root emits).
No merge/rebase primitives exist: content combination happens in the codegen workshop and re-enters as a multi-parent register_spell; attach covers the organizational "move my line onto that base" act.
- Threading:
Instance RLock serializes verbs; child structures carry their own locks; lock order is set -> child, one-way.
- Lifecycle:
Owned by exactly one MutationResearch root; cleanup() cascades into lanes, journal, residence, and versioner; idempotent; lock released last.
- Registration:
MELDER KERNEL - guarded. Obtained through MutationResearch.research_set() or create_research_set(); a user drives it rather than registering it.
- WHY LANES ARE READ-ONLY IN PUBLIC HANDS:
ResearchLane objects are handed out LIVE, but every lane mutator is set-internal. That is not encapsulation for its own sake: residence claims, journal entries, organization snapshots, and persistence emission all hang off THIS object. A public lane mutator would let a caller change the network while bypassing all four, leaving the partition, the history, and the record disagreeing about what happened.
So the rule is: public callers read lanes, the owning set writes them.
- Subsystem Context:
The agent-facing surface of the whole package, composing four bookkeeping structures that each answer one question - ResidenceRegistry (is this identity already somewhere), ResearchJournal (what happened, in order), NetworkVersioner (what the organization used to look like), and the lanes themselves (where things are now). It deliberately never touches the crystallizer: it fires on_mutation and the ROOT does the emitting.
- System Context:
This is where the model's central conviction lives - full-object records rather than diffs. A version is stored whole, so understanding change is a derived READ through the diff engines rather than a reconstruction. The absence of merge and rebase follows from the same conviction: combining content happens in the codegen workshop and re-enters as a multi-parent register_spell, so the record only ever gains facts. It never rewrites them.
AGENT_ACCESS: public
- AGENT_PURPOSE:
access: public. One research network - lanes, journal, residence partition. Use register_spell/register_group, create_lane, attach/detach, join, archive, walk/history/heads, campaign_view, snapshot_network/restore_network.
- cleanup() None[source]๏
Cascade cleanup into owned structures and mark the set cleaned.
Contract:
IDEMPOTENT under double-checked locking: the _cleaned flag is tested before AND after taking the lock, so concurrent callers cannot both run teardown.
CHILDREN FIRST, in dependency order: every lane, then the journal, residence registry and versioner, each under its own best-effort try/except so one failing child cannot strand the rest.
DELETE-NOT-NULL posture. Owned fields are removed with del, not set to None - there are no retained tombstones, so any use after cleanup raises AttributeError rather than reading a stale value.
THE LOCK IS DELETED LAST, outside the guarded block, after every owned reference is gone. That ordering means the object is fully stripped before the lock that protected the teardown disappears.
Does NOT fire on_mutation. Cleanup is a teardown, not a mutating verb, so no persistence emission is triggered.
- Threading:
The teardown body runs under self._lock; the final del self._lock is the one step outside it.
- Returns:
None.
- property lane_type_enforcement: bool๏
Return whether type-mixing joins currently require force.
Contract:
Reports whether lane-type rules are enforced for this set. When off, lane-type mismatches are permitted rather than rejected, so a set can hold members its lane types would otherwise refuse.
- Threading:
Reads under self._lock, so the result is a coherent snapshot.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
True when the join gate is armed.
- Return type:
bool
- set_lane_type_enforcement(enabled: bool) None[source]๏
Arm or disarm the lane-type join gate.
- Purpose:
The root propagates the configured lane_type_enforcement posture here at activation (and onto sets created afterwards); the set itself stays configuration-free and standalone-testable.
Contract:
GATES ONLY join. Arming this does not retroactively touch lanes or members already placed; it changes what a FUTURE type-mixing join requires (force=True). Existing mixed content stays.
COERCES to bool with bool(enabled) rather than type-checking, so any truthy value arms the gate. It never raises on argument type.
The set holds this flag itself and owns no configuration object - the root pushes the posture in, which is what keeps a ResearchSet constructible and testable without a live configuration. Two sets can therefore carry different enforcement postures.
Idempotent: setting the same value again is a harmless re-write.
- Threading:
The assignment happens under self._lock, so a concurrent join reads a fully-written flag rather than a torn value.
- Parameters:
enabled -- Whether type-mixing joins require force=True.
- Returns:
None.
- property set_id: str๏
Return the stable set id (ULID).
Contract:
Identifies THIS RESEARCH SET OBJECT and is distinct from name - the id is stable machine identity, the name is the human-facing key used for lookups.
- Threading:
Unsynchronized read of a slot fixed at construction.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Set id.
- Return type:
str
- property name: str๏
Return the set name.
Contract:
The human-facing registry key for this set, distinct from set_id. Lookups on MutationResearch are by NAME, not by id.
- Threading:
Unsynchronized read of a slot fixed at construction.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Set name.
- Return type:
str
- property default_lane: ResearchLane๏
Return the guaranteed default lane.
Contract:
Resolves the set's DEFAULT lane by its well-known name, so it is a lookup rather than a stored reference - the default lane is whatever currently answers to that name.
Operations that omit a lane land here.
- Threading:
Reads under self._lock, so the result is a coherent snapshot.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
The set's default lane.
- Return type:
- property journal: ResearchJournal๏
Return the set-level forward-only journal.
Contract:
Exposes the set's journal BY REFERENCE. It is owned by this set and becomes invalid once the set is cleaned; do not clean it separately.
- Threading:
Unsynchronized read of a slot fixed at construction.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Owned journal (append-only; callers read, never edit).
- Return type:
ResearchJournal
- get_lane(lane_ref: str) ResearchLane[source]๏
Return one lane by name or id.
Contract:
Returns the LIVE lane object, not a copy or snapshot. Its contents change as the set changes.
THE LANE IS A READ SURFACE ONLY. Every mutator on ResearchLane is set-internal (_add_node, _detach_nodes, _set_anchor, _mark_joined, _mark_archived), so holding this object does not let a caller move nodes, re-anchor, or terminate the lane behind the set's back. Residence claims, the journal, snapshots and persistence emission cannot be bypassed through it. All state change goes through set verbs.
Resolves by NAME or ID; a miss raises KeyError rather than returning None, so the return value never needs a null check.
- Threading:
Resolution happens under self._lock, but the returned object is live - subsequent reads of it are not covered by that lock.
- Parameters:
lane_ref -- Lane name or lane id.
- Returns:
Resolved lane.
- Return type:
- Raises:
KeyError -- If nothing matches.
- lane_names() List[str][source]๏
Return every lane name in this set, sorted.
Contract:
SORTED, so iteration order is deterministic across calls and processes.
Lists ALL lanes regardless of state, including joined and archived ones
unlike heads(), which reports open lanes only.
- Threading:
Reads under self._lock, so the result is a coherent snapshot.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Sorted lane names.
- Return type:
List[str]
- residence_of(spell_id: str) str | None[source]๏
Return the lane id holding one identity, when resident.
- Parameters:
spell_id -- Identity to look up.
Contract:
Returns the lane an identity currently RESIDES in. None means it has no residence in this set - a normal answer, not an error, and the reason this returns Optional[str] while history(...) raises for the same condition.
SINGLE-RESIDENCE INVARIANT: an identity lives in exactly ONE lane network-wide, permanently. There is no release verb, so a non-None answer here never becomes None again for that identity; it can only change lane through a set verb that transfers residence.
Answers residence only. It does not assert the holding lane still carries the node, nor that the lane is open - pair it with history(...) when lane state matters.
- Threading:
Does NOT take the set lock. It delegates to ResidenceRegistry, which serializes the lookup under its own lock, so the answer is a point-in-time read of the residence partition rather than a snapshot consistent with the set's lanes.
- Lifecycle / Cleanup:
Guarded by check_cleaned() here, and again inside the registry.
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Holding lane id or None.
- Return type:
Optional[str]
- heads() Dict[str, str | None][source]๏
Return the tip identity of every OPEN lane.
Contract:
OPEN LANES ONLY. Joined and archived lanes are OMITTED entirely, so the key set here is a subset of lane_names() and a missing name means "not open", not "does not exist".
A value of None means the lane is open but has NO TIP yet - distinct from the lane being absent. Both cases exist and mean different things.
- Threading:
Reads under self._lock, so the result is a coherent snapshot.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
lane name -> tip SHA256 (None for empty lanes).
- Return type:
Dict[str, Optional[str]]
- walk(lane_ref: str) List[Dict[str, object]][source]๏
Return one lane's line of versions with its ancestry hop.
- Purpose:
The read verb for "tell me this object's story": ordered full-object records plus the anchor pointer that lets the caller hop into the transitive network.
Contract:
ONE LANE ONLY. This does not traverse ancestry. Every returned row carries anchor_lane_id / anchor_spell_id so the caller can make the next hop deliberately, but walking the transitive network is the caller's loop, not this verb's.
Rows are DETACHED payloads built from node.describe(), then stamped with the four lane/anchor fields. Mutating a row cannot affect the record.
Order is the lane's own node order - declaration order, oldest first.
An UNANCHORED lane still returns rows; its anchor_lane_id and anchor_spell_id are simply empty, so absence of an anchor is data rather than an error.
Accepts either a lane NAME or a lane ID; resolution failure raises rather than returning an empty list, so [] unambiguously means "this lane exists and holds no versions".
- Threading:
Resolves and materializes the whole list under self._lock, so the returned sequence is a coherent snapshot rather than a live view.
- Parameters:
lane_ref -- Lane name or lane id.
- Returns:
Ordered node payloads; each carries lane_id, lane_name, anchor_lane_id, and anchor_spell_id alongside the node fields.
- Return type:
List[Dict[str, object]]
- history(spell_id: str) Dict[str, object][source]๏
Return everything the record knows about one identity.
Contract:
RESIDENCE-KEYED. The identity must be resident in THIS set; a non-resident id raises KeyError naming the set rather than returning an empty payload, so there is no ambiguity between "unknown here" and "known but empty".
Joins three sources into one detached payload: the holding lane's identity/state/type, the node record itself, and EVERY journal entry touching the identity - including events recorded while the identity sat in a different lane, because the journal is queried by spell id rather than by lane.
lane_state and lane_type are emitted as their enum .value strings, not enum members, so the payload stays JSON-safe.
Read-only: it resolves, materializes and returns; nothing is claimed, journalled or snapshotted.
- Threading:
Residence lookup, lane read, node describe and journal scan all run under self._lock, so the returned payload is internally consistent.
- Parameters:
spell_id -- Identity to report on.
- Returns:
Payload with the holding lane, the node record, and every journal event touching the identity.
- Return type:
Dict[str, object]
- Raises:
KeyError -- If the identity is not resident in this set.
- campaign_view(campaign: str) Dict[str, object][source]๏
Return everything the record knows about one research campaign.
- Purpose:
Campaigns stamp work ACROSS lanes (multi-agent research); this read gathers the stamped version records and journal events into one detached payload without any organizational side effects.
Contract:
JOURNAL ORDER IS THE CAMPAIGN'S STORY, and that is deliberate, not incidental. Nodes are sequenced by walking journal entries rather than by iterating lanes, because lane iteration tie-breaks same-millisecond ULIDs on their RANDOM component - that produced a non-deterministic full-tree flake. Do not "optimize" this into a lane walk.
TRANSITIONS AND NODES ARE NOT THE SAME SET. Every stamped journal entry becomes a transition, but only four acts contribute a node: registered, staged, group_registered, group_recomposed. A campaign of pure organizational moves yields transitions with an empty node list, which is correct rather than a gap.
MISSING IDENTITIES ARE REPORTED, NOT HIDDEN. An identity that was journalled but is absent from the CURRENT organization - a network restore rewound past its declaration, for instance - still emits a node entry carrying missing_from_current_organization: True with null lane fields. The read never silently drops a stamped event.
lane_names unions lanes seen from BOTH sides: the lane each journal entry was recorded against, and the lane currently holding each resolved node. Those can differ after a move.
Read-only. No claim, journal write, or snapshot occurs.
An empty or non-string campaign raises ValueError up front.
- Threading:
The whole gather runs under self._lock, so transitions and nodes cannot disagree about the organization mid-read.
- Parameters:
campaign -- Campaign stamp to gather.
- Returns:
Payload with campaign, stamped nodes (each carrying its holding lane), stamped transitions, and the sorted lane_names involved.
- Return type:
Dict[str, object]
- create_lane(name: str, *, lane_type: str | None = None, attach_to: str | None = None, attach_at_spell_id: str | None = None, actor: str | None = None, campaign: str | None = None, reason: str | None = None, metadata: Dict[str, object] | None = None) ResearchLane[source]๏
Create one open lane, optionally anchored onto an existing node.
Contract:
ANCHORING IS ALL-OR-NOTHING. attach_to and attach_at_spell_id must be supplied together; exactly one of them raises ValueError before the lock is taken. There is no "anchor to the lane's tip" shorthand - the anchor node is always named explicitly.
The anchor node must be held by the named lane. A lane that exists but does not hold that identity raises KeyError naming both, so a wrong-lane anchor cannot silently produce an unanchored lane.
Anchoring records ANCESTRY ONLY. The new lane starts EMPTY; no node is copied or moved from the anchor, and the anchor lane is untouched.
Lane NAMES are freeform and unique per set; lane TYPE is the policy word. Omitting lane_type yields experiment, not the default lane's development - a freshly created lane is an experiment until stated otherwise.
The journal entry records the anchor identity as from_spell_id and carries lane_type plus the anchor lane's id and name in metadata, so the lane's origin survives in history even if the anchor lane is later archived.
Ordering is validate -> resolve anchor -> create -> set anchor -> journal -> snapshot. A failure at any step leaves no lane, no journal entry and no snapshot.
- Threading:
Campaign and anchor-argument validation run outside the lock; lane creation, anchoring, journalling and snapshotting run under self._lock. _notify_mutation() fires after release.
- Parameters:
name -- Unique lane name within this set.
lane_type -- Optional policy vocabulary word (LaneType value: development/experiment/production/test); experiment when omitted. Names stay freeform; the type is the policy word.
attach_to -- Optional lane (name or id) to anchor onto; requires attach_at_spell_id.
attach_at_spell_id -- Node identity within attach_to to anchor at.
actor -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
metadata -- Optional value-typed annotations.
- Returns:
Newly created lane.
- Return type:
- Raises:
ValueError -- If the name is taken, the lane_type is unknown, or anchor arguments are half-supplied.
KeyError -- If attach_to does not resolve, or the anchor node is not held by that lane.
- register_spell(spell_id: str, *, lane: str | None = None, module_source_sha256: str | None = None, parent_spell_ids: List[str] | None = None, author: str | None = None, campaign: str | None = None, reason: str | None = None, metadata: Dict[str, object] | None = None) ResearchNode[source]๏
Formally declare one bound version as research.
- Purpose:
The world-entry verb: the version already exists (it was bound; its SHA is minted; custody rides the crystal that shares the same id). This records that the version is part of a research stream.
Contract:
DECLARES, never creates. The version must already have been bound; this writes the research record for it and mints no runtime state.
Omitting lane targets the guaranteed default lane rather than failing, so the common call needs no lane vocabulary at all.
ANCESTRY IS VALIDATED IN TWO STAGES, both ValueError. A parent that is not resident in this set is rejected outright. A parent that IS resident but names a COMPOSITION rather than a spell version is rejected separately: compositions are informational and carry no source or custody state, so there is nothing for a spell version to inherit from one.
CLAIM-THEN-ADD WITH COMPENSATION. Residence is claimed before the node is added to the lane, and a failed add rolls that claim back before re-raising. Lanes are handed out live, so a direct terminal-state call can race between the two steps under real threads; without the rollback a refused add would strand the claim and corrupt the residence partition.
Journals TransitionAct.registered and snapshots the organization only after the add succeeds - a refused registration leaves no journal entry and no snapshot.
Mutation subscribers are notified AFTER the set lock is released, which is what keeps the one-way lock order intact.
- Threading:
The campaign check runs outside the lock; lane resolution, ancestry validation, the claim/add pair, journalling and snapshotting all run under self._lock. The _notify_mutation() fan-out is deliberately outside it.
- Parameters:
spell_id -- Binding-signature SHA256 (doubles as the custody crystal id).
lane -- Optional lane (name or id); the guaranteed default lane records the version when omitted.
module_source_sha256 -- Optional module-version SHA256 the version binds against.
parent_spell_ids -- Optional ancestry; every parent must already be resident in this set (multi-parent = codegen-workshop composition).
author -- Optional registering agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
metadata -- Optional value-typed annotations.
- Returns:
The recorded version node.
- Return type:
ResearchNode
- Raises:
RuntimeError -- Rediscovery - the identity already resides in a lane (the error names it); or the target lane is not open.
ValueError -- If a parent identity is unknown to this set, or names a composition (group) identity instead of a spell version.
- register_group(member_spell_ids: List[str], *, lane: str | None = None, parent_group_ids: List[str] | None = None, author: str | None = None, campaign: str | None = None, reason: str | None = None, metadata: Dict[str, object] | None = None) GroupedResearchNode[source]๏
Formally declare one subsystem composition (GroupedResearchNode).
- Purpose:
The grouped world-entry verb (owner ruling 2026-07-11): pin a set of already-declared versions as ONE composition record. Purely informational - members keep their own lanes, custody, and runtime posture untouched; the composition gates nothing.
Contract:
Every member must already be formally declared (resident) in this set AND be a spell version - the spell/group namespaces are enforced separately, so a composition identity can never ride as a member or as spell ancestry.
Identity is content-addressed over the member set: an identical roster IS the same identity, so re-registering an unchanged composition surfaces the rediscovery error naming the holding lane (not a new fact).
parent_group_ids must reference resident compositions (composition ancestry, separate from spell ancestry).
- Parameters:
member_spell_ids -- Non-empty member identities to pin.
lane -- Optional lane (name or id); the guaranteed default lane records the composition when omitted (subsystems deserve their own lane - create one and pass it here).
parent_group_ids -- Optional previous composition identities.
author -- Optional registering agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
metadata -- Optional value-typed annotations.
- Returns:
The recorded composition node.
- Return type:
GroupedResearchNode
- Raises:
RuntimeError -- Rediscovery - the composition identity already resides in a lane (the error names it); or the target lane is not open.
ValueError -- If a member or parent composition is unknown to this set, if a member names a composition identity, or if a parent composition names a spell identity.
- recompose_group(previous_group_id: str, *, add: List[str] | None = None, remove: List[str] | None = None, author: str | None = None, campaign: str | None = None, reason: str | None = None, metadata: Dict[str, object] | None = None) GroupedResearchNode[source]๏
Evolve one composition forward (the iterate-and-add flow).
- Purpose:
The agent loop the owner described: keep adding spells into the composition. Reads the previous roster, applies adds and removes, and registers the NEW composition into the SAME lane with parent_group_ids=[previous] - forward-only; nothing is edited.
Contract:
FORWARD-ONLY. The previous composition is never mutated; a NEW GroupedResearchNode is registered with the previous as its single parent. History is append; there is no in-place edit.
CONTENT-ADDRESSED IDENTITY MAKES A NO-OP AN ERROR. A roster that resolves back to the previous member set is the SAME identity (content-addressed), so it raises rather than recording a duplicate. Removing then re-adding the same members is a no-op and is rejected.
REMOVE IS STRICT, ADD IS PERMISSIVE-BUT-VALIDATED. A remove of a member not in the previous roster raises; an add of a member not resident in the set raises (via the delegated register_group). Removes are applied before adds.
The result must pin at least one member; an empty roster raises.
Lands in the SAME lane the previous composition resides in - a recompose stays on its subsystem timeline rather than starting a new one.
RELEASES THE SET LOCK BEFORE DELEGATING. Validation runs under the lock; the actual registration re-enters through the public register_group, which takes the lock again. The operation is therefore two locked sections, not one - a concurrent mutation can interleave between the roster computation and the registration, and register_group re-validates residence at that point.
- Threading:
Roster validation runs under self._lock; the lock is released and register_group re-acquires it for the write. Not a single atomic critical section.
- Parameters:
previous_group_id -- The composition being evolved (must be resident).
add -- Member identities to add (must be resident).
remove -- Member identities to drop (must be in the previous roster).
author -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
metadata -- Optional value-typed annotations.
- Returns:
The new composition node.
- Return type:
GroupedResearchNode
- Raises:
RuntimeError -- If the identity is unknown, the resident node is not a composition, the resulting roster is unchanged (identical member set = same content address = the SAME composition; nothing new to record), or the holding lane is not open.
ValueError -- If a removed member is not in the previous roster, an added member is not resident, or the resulting roster is empty.
- group_history(group_id: str, *, campaign: str | None = None) Dict[str, object][source]๏
Return everything the journal knows about one subsystem area.
- Purpose:
The temporal composition read ("track changes in a larger area to see what happened"): every journal event that touches the composition's OWN lane (the subsystem timeline), any pinned member identity, or any lane a pinned member resides in - in journal order, campaign stamps intact. With a campaign the story narrows to one effort inside the area (the WHERE x WHEN join: groups are structure, campaigns are intent; neither owns the other, so the record joins them).
Contract:
THREE-SOURCE WATCH SET. An event is included if it touches the composition's OWN lane, any member identity it pins, OR any lane a pinned member currently resides in. The returned watched_lane_ids names exactly the lanes that widened the search, so the scope is inspectable rather than implicit.
The subject must be a COMPOSITION. A resident id that is a spell version raises RuntimeError; an unknown id raises too. It never returns an empty payload for a wrong-kind subject.
campaign is an AND filter, not a selector: it narrows the already composition-scoped events to one campaign. Omitting it returns the full area story; supplying an unstamped campaign yields entries empty rather than raising.
Events are in JOURNAL ORDER (declaration order), and the payload is fully detached - describing entries, not live journal objects.
Read-only: no claim, journal write or snapshot.
- Threading:
Resolves the composition, computes the watch set and scans the journal under self._lock, so the member roster and the events cannot disagree mid-read.
- Parameters:
group_id -- Composition identity to gather around.
campaign -- Optional campaign stamp to narrow to.
- Returns:
{"group_id", "lane_id", "member_spell_ids", "watched_lane_ids", "campaign", "entries"} - entries in journal order.
- Return type:
Dict[str, object]
- Raises:
RuntimeError -- If the identity is unknown, or resident but a spell version.
- record_world_entry(spell_id: str, *, staged: bool = False, lane: str | None = None, module_source_sha256: str | None = None, parent_spell_ids: List[str] | None = None, author: str | None = None, campaign: str | None = None, reason: str | None = None, metadata: Dict[str, object] | None = None) ResearchNode | None[source]๏
Idempotently declare one world-entry (the runtime-seam verb).
- Purpose:
The bind/bind_inactive seams call this on EVERY dynamic-lane world entry: identical content rebinds to the same SHA, so an already-resident identity is a quiet no-op here (the runtime must never fail on research bookkeeping), while a fresh identity registers exactly like register_spell - with the staged act when the entry was parked.
Contract:
IDEMPOTENT BY DESIGN, and this is the ONLY difference in spirit from register_spell: an already-resident identity returns None instead of raising. Rediscovery is the expected steady state on a runtime seam - identical content rebinds to the same SHA - and the runtime must never fail because bookkeeping already happened.
THE RESIDENCE CHECK IS FIRST, before lane resolution and before ancestry validation. A rediscovery therefore costs one lookup and validates nothing; it will NOT raise on a bad lane or a bad parent that a fresh registration would have rejected.
staged selects the journal act only: staged for bind_inactive parks, registered for active binds. It does not change residence, lane placement, or node shape.
Ancestry rules mirror register_spell exactly - unknown parent raises, composition-as-parent raises - and this is the path the root's staged-ancestry stamp travels for synthesis mints.
Carries the same CLAIM-THEN-ADD compensation: residence is claimed before the add and rolled back if the add is refused, so a race cannot strand a claim and corrupt the residence partition.
- Threading:
The residence probe, lane resolution, ancestry validation, claim/add pair, journalling and snapshotting all run under self._lock; _notify_mutation() fires after release.
- Parameters:
spell_id -- Binding-signature SHA256 entering the world.
staged -- True for bind_inactive entries (journals staged); False for active binds (journals registered).
lane -- Optional lane (name or id); default lane when omitted.
module_source_sha256 -- Optional module-version SHA256.
parent_spell_ids -- Optional ancestry (the synthesis-mint lane: the root's staged-ancestry stamp routes through here); every parent must already be resident in this set, mirroring register_spell.
author -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
metadata -- Optional value-typed annotations.
- Returns:
The new version node, or None when the identity was already declared (rediscovery is not an error on this verb).
- Return type:
Optional[ResearchNode]
- Raises:
ValueError -- If a parent identity is unknown to this set, or names a composition (group) identity instead of a spell version.
- record_promotion(from_spell_id: str | None, to_spell_id: str, *, actor: str | None = None, campaign: str | None = None, reason: str | None = None, metadata: Dict[str, object] | None = None) TransitionEntry[source]๏
Record one runtime selection change (notch) as a forward event.
Contract:
Journal-only: promotion changes what is LIVE, never which lane holds a version, so the organization does not re-snapshot.
to_spell_id must be a declared identity; from_spell_id may be None (first selection) or an identity outside the record (honest passthrough - pre-MR history is not fabricated).
- Parameters:
from_spell_id -- Previously selected identity, when known.
to_spell_id -- Newly selected identity (must be resident).
actor -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
metadata -- Optional value-typed annotations.
- Returns:
The recorded promoted event.
- Return type:
TransitionEntry
- Raises:
KeyError -- If to_spell_id is not resident in this set.
- attach(lane_ref: str, *, onto: str, at_spell_id: str, actor: str | None = None, campaign: str | None = None, reason: str | None = None) None[source]๏
Anchor one lane's ancestry onto another lane's node.
Contract:
Organization only: content never moves; onto/at_spell_id are mandatory so the act is never scope-blind.
REPOINTS RATHER THAN APPENDS. A lane holds at most ONE anchor, so attaching a lane that is already anchored silently replaces the previous anchor. Ancestry here is a single pointer, not a list; use the journal to recover where a lane used to hang.
SELF-ANCHORING IS REFUSED by lane id, not by name, so two names resolving to the same lane are still caught.
The anchor node must be held by onto. A lane that resolves but does not hold the identity raises KeyError naming both.
The journal records the anchor identity as to_spell_id (the direction that distinguishes it from detach, which records the vacated anchor as from_spell_id) plus the anchor lane's id and name in metadata.
Snapshots the organization on success, so an anchor change is recoverable through restore_network.
- Threading:
Both lane resolutions, the self-anchor check, the anchor write, journalling and snapshotting run under self._lock; _notify_mutation() fires after release.
- Parameters:
lane_ref -- Lane (name or id) being organized.
onto -- Lane (name or id) to anchor onto.
at_spell_id -- Node identity within onto to anchor at.
actor -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
- Raises:
RuntimeError -- If the subject lane is not open, or anchoring onto itself.
KeyError -- If lanes or the anchor node do not resolve.
- Returns:
None.
- detach(lane_ref: str, *, actor: str | None = None, campaign: str | None = None, reason: str | None = None) None[source]๏
Remove one lane's ancestry anchor.
Contract:
Organization only. The lane keeps every node it holds; only the ancestry pointer is cleared, so a detached lane becomes a root of its own line rather than losing content.
NOT IDEMPOTENT. Detaching a lane that holds no anchor raises RuntimeError rather than returning quietly, so a redundant detach is a caller error rather than a silent no-op.
The vacated anchor is captured BEFORE the clear and journalled as from_spell_id, which is what makes the detach reversible from history - the pointer would otherwise be unrecoverable.
Snapshots the organization on success.
- Threading:
Resolution, anchor capture, clear, journalling and snapshotting run under self._lock; _notify_mutation() fires after release.
- Parameters:
lane_ref -- Lane (name or id) being organized.
actor -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
- Raises:
RuntimeError -- If the lane is not open or holds no anchor.
- Returns:
None.
- join(lane_ref: str, *, into: str, collapse: bool = False, force: bool = False, actor: str | None = None, campaign: str | None = None, reason: str | None = None) ResearchLane[source]๏
Finish one lane into a receiving lane; the source archives.
Contract:
Divergence-aware: the clean path requires the source to be anchored onto into with the receiving tip still AT the anchor. Any other arrangement (moved tip, foreign anchor, no anchor) is a divergent join and requires force=True - the explicit supersede. Reconciliation-by-content is not a join concern: compose in the codegen workshop, register the multi-parent result, then join.
collapse=False (default) folds the source's full line into the receiver; collapse=True moves only the tip, leaving the rest readable in the joined (terminal) source container.
Residence transfers with the moved records; the source is marked joined and accepts no further work.
- Parameters:
lane_ref -- Source lane (name or id) to finish.
into -- Receiving lane (name or id).
collapse -- Move only the tip when True.
force -- Permit a divergent join (explicit supersede).
actor -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
- Returns:
The receiving lane.
- Return type:
- Raises:
RuntimeError -- On self-join, non-open lanes, or unforced divergence (the error names both tips).
- archive(lane_ref: str, *, actor: str | None = None, campaign: str | None = None, reason: str | None = None) None[source]๏
Retire one dead-end lane from the active view.
Contract:
The default lane never archives.
Residence stays permanent: rediscovery keeps pointing at the archived container; network snapshots can restore views that contained it.
- Parameters:
lane_ref -- Lane (name or id) to archive.
actor -- Optional acting agent name.
campaign -- Optional research-campaign stamp.
reason -- Optional reason line.
- Raises:
RuntimeError -- If targeting the default lane or a non-open lane.
- Returns:
None.
- snapshot_network() str[source]๏
Explicitly snapshot the current organization.
Contract:
Builds a full network snapshot under the lock, so it is internally consistent - but it is a point-in-time copy that does not track later changes.
Cost scales with the network, so this is a deliberate operation rather than a cheap accessor.
- Threading:
Reads under self._lock, so the result is a coherent snapshot.
- Lifecycle / Cleanup:
Guarded by check_cleaned().
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Content address of the organization snapshot (mutating verbs already snapshot automatically; this is the manual seal).
- Return type:
str
- network_snapshot_shas() List[str][source]๏
Return retained organization snapshot addresses, oldest first.
Contract:
Returns the versioner's recorded snapshot digests, which identify snapshots WITHOUT materializing them - use it to detect change cheaply instead of calling snapshot_network() repeatedly.
Ordered OLDEST FIRST, matching the FIFO retention ring, so the tail is the newest and [-1] equals latest_network_snapshot.
A DETACHED copy of the address list; mutating it cannot affect the retention ring.
Lists only RETAINED addresses. The ring is bounded, so an address observed earlier can age out and later return KeyError from restore_network - presence here is the honest liveness test.
- Threading:
Does NOT take the set lock. It delegates to NetworkVersioner, which serializes the read under its own lock, so the result is a point-in-time view of the retention ring rather than a snapshot consistent with the set's live lanes.
- Lifecycle / Cleanup:
Guarded by check_cleaned() here; the versioner guards its own read.
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Detached ordered address list.
- Return type:
List[str]
- property latest_network_snapshot: str | None๏
Return the newest retained organization snapshot address.
Contract:
The most recent snapshot digest. Comparing it against a previously held value is the cheap way to ask "has the network changed since I looked", with no snapshot build required.
None means NO snapshot has ever been taken - a fresh set before its first organizing verb - not an error.
Equals the last element of network_snapshot_shas().
Content-addressed, so an organization that changes and then reverts to a previous shape reports the SAME digest again: this detects SHAPE change, not event count. Two structurally identical organizations are indistinguishable here by design.
- Threading:
Does NOT take the set lock. It delegates to NetworkVersioner, which serializes the read under its own lock; the answer is a point-in-time read of the retention ring.
- Lifecycle / Cleanup:
Guarded by check_cleaned() here; the versioner guards its own read.
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
Newest address or None.
- Return type:
Optional[str]
- restore_network(snapshot_sha: str, *, actor: str | None = None, reason: str | None = None) None[source]๏
Rebuild the organization from one content-addressed snapshot.
Contract:
Recovery mechanic for organizational mistakes: lanes and residence rebuild wholesale from the snapshot; the journal is untouched (history is never rewound) and the restore itself is journaled forward as a restored event.
VALIDATE-THEN-DESTROY. The incoming payload is parsed, every lane rebuilt, and the guaranteed default lane confirmed present BEFORE a single live lane is torn down. A malformed payload, or one lacking the default lane, raises with LIVE ORGANIZATION UNTOUCHED. A restore therefore never half-lands.
The default-lane gate exists because default_lane resolves by well-known name: installing a network without it would leave the set unable to serve its own default, so the invariant is checked at the door rather than discovered later.
WHOLESALE REPLACEMENT, not a merge. Lanes, the name index and the residence partition are all swapped for the rebuilt objects; any lane or residence claim created after the snapshot is discarded.
Old lanes and the old residence registry are cleaned best-effort during the swap - a failure to clean one does not abort the restore, because the replacement is already validated.
The restore target is a NETWORK SNAPSHOT ADDRESS, not a spell identity, so it rides metadata["snapshot_address"] rather than a typed endpoint field. The typed fields never carry something that is not an identity.
Snapshots again on success, so the pre-restore organization is itself addressable afterwards.
- Threading:
The entire validate-teardown-swap-journal sequence runs under self._lock, so no reader observes a half-swapped organization. _notify_mutation() fires after release.
- Parameters:
snapshot_sha -- Content address to restore.
actor -- Optional acting agent name.
reason -- Optional reason line.
- Raises:
KeyError -- If the address is unknown (possibly aged out of retention).
ValueError -- If the snapshot's organization payload is invalid or lacks the guaranteed default lane (live state stays untouched).
- Returns:
None.
- describe_composition(*, recent_transitions: int | None = 200) Dict[str, object][source]๏
Return the detached persistence payload for this set.
- Purpose:
The twin feed: full organization plus a bounded recent journal window (checkpoints capture the deltas over time, so the twin never needs the unbounded stream).
Contract:
The ORGANIZATION is captured in full, but the JOURNAL is a bounded WINDOW (default 200 recent entries). This is deliberate: the twin carries current structure completely, while journal HISTORY is left to the checkpoint sequence, which records deltas over time. Passing recent_transitions=None includes the full stream when a complete history is genuinely needed.
Carries the NETWORK VERSIONER's own state (network_versioner), so a set rebuilt from this payload can still restore_network to pre-death organization shapes - the undo ring survives the round trip, bounded by its retention.
PLAIN-VALUE THROUGHOUT. Every nested value is JSON-safe, so the payload crosses the persistence boundary losslessly and rebuilds via from_payload.
A detached copy: mutating the returned dict cannot affect the set.
Read-only; no snapshot or journal write occurs.
- Threading:
The whole payload is assembled under self._lock, so organization, journal window and versioner state are mutually consistent.
- Parameters:
recent_transitions -- Journal window bound; None includes the full stream.
- Returns:
Plain-value payload with organization, journal, and network_snapshot_shas.
- Return type:
Dict[str, object]
- describe() Dict[str, object][source]๏
Return the default detached snapshot of this set.
Contract:
ALIAS for describe_composition(), delegating in full and adding nothing. It carries no guard of its own; the cleaned-state check comes from the method it delegates to.
- Threading:
Inherited from describe_composition().
- Lifecycle / Cleanup:
Guarded indirectly, via the delegate.
- Raises:
RuntimeError -- If the research set has been cleaned.
- Returns:
describe_composition() with the default journal window.
- Return type:
Dict[str, object]
- classmethod from_payload(payload: Dict[str, object], *, on_mutation: Callable[[], None] | None = None) ResearchSet[source]๏
Rebuild one set from a describe_composition() payload.
- Purpose:
The hydration seam: a recorded composition (from the MutationResearchCrystal twin) rebuilds into a live set. The journal rebuilds from the recorded window and continues minting beyond the recorded next_sequence.
Contract:
BUILDS FRESH, THEN REPLACES. It constructs a normal set (which eagerly makes a default lane), then tears that scaffolding down and swaps in the rebuilt lanes, residence, journal and versioner. The transient default lane exists only to satisfy the constructor and does not survive.
on_mutation IS SUPPRESSED DURING REBUILD. The set is constructed with on_mutation=None so hydration emits nothing, and the caller's callback is installed only at the very end. A rebuild is therefore silent - it does not re-fire persistence for every recorded node.
RECORDED IDENTITY IS PRESERVED. The rebuilt set restores the recorded set_id and created_at rather than minting new ones, so a hydrated set is the SAME identity it was sealed as.
PARTIAL PAYLOADS DEGRADE, they do not crash: a missing residence payload rebuilds an empty registry, a missing versioner payload keeps the fresh one. Only organization and journal are hard requirements; their absence or wrong type raises ValueError.
The journal continues minting BEYOND the recorded window's next_sequence, so sequence numbers never collide across a rebuild.
Snapshots once at the end, so the rebuilt organization is immediately addressable.
- Threading:
The rebuilt set is not shared until this returns; the swap runs under the new set's own lock.
- Parameters:
payload -- Detached payload produced by describe_composition().
on_mutation -- Optional callback installed on the rebuilt set.
- Returns:
Reconstructed set.
- Return type:
- Raises:
ValueError -- If the payload shape is invalid.