ADR-0129: Ordering multiplicity and holder-side coordination for collections¶
- Status: Proposed
- Kind: Aspirational
- Implementation-status: not-started
- Area: core-data-model
- Date: 2026-10-16
- Relations: extends ADR-0002 (UUID-keyed ordered collection model), whose D4 and D6 leave the two questions decided here open and whose delta rows 3 and 4 carry the code work; revises ADR-0006 D4, ADR-0004 D5, and ADR-0092 D3, which rely on the collection locks this record removes
Context¶
ADR-0002 records what the collection model is today and lists two questions it deliberately does not decide: what decides a Progression's multiplicity, and where the coordination of concurrent access lives. Both are targets rather than observations, and the shipped code contradicts both. This record decides them and hands the code work back to ADR-0002's delta rows, so the retrospective record keeps describing the code.
P1 — one ordering class carries two rules and applies them per method. Progression (lionagi/protocols/generic/progression.py:139) is a single class whose duplicate behavior depends on the method called. include (:270-286, set-like), exclude (:288-300, removes every occurrence), and membership (:200-205, "at least once") behave as a set; construction (:186-188 returns deque(validate_order(value)) unchanged, so a repeated id is kept, as the edge case in tests/protocols/generic/test_progression_edge_cases.py:58-62 shows), append (:302-314), extend (:364-369), insert (:392-398), += (:379-381), and positional assignment (:225-244) store additional positions for an id already present. A reader cannot tell from a signature whether a call may duplicate an id, and neither can a caller that holds the object as Ordering[T].
P2 — uniqueness holds only where Pile's own code enforces it. Pile's constructor rejects a duplicate id in a supplied order (lionagi/protocols/generic/pile.py:141-150), include adds only ids absent from the order (:315-319), and _insert (:838-847) and _setitem (:719-759) raise ItemExistsError for an id already resident. The rule therefore lives in the collection's methods rather than in the ordering type, and the ordering type's own mutators are free to break the collection's invariant whenever a caller holds the order directly.
P3 — a caller-supplied order is aliased, not copied. _validate_progression returns the Progression instance it was given (pile.py:135-137, :150), so Pile(order=prog) installs the caller's object as the collection's order, and Element.model_config (lionagi/protocols/generic/element.py:37-42) neither revalidates nor copies an instance assigned to a model-typed field. A caller that keeps prog can append a second position for a resident id with prog.append(uid) and change the collection's order without touching the collection.
P4 — every collection type carries locks its holder cannot see the cost of. Pile owns an RLock and an AnyIO lock (pile.py:220-221) and reaches them through three shapes: @synchronized (lionagi/ln/_utils.py:485-494) takes the RLock alone, @async_synchronized (:497-526) takes the AnyIO lock and then spins on the RLock with a non-blocking acquire and a 0.5 ms sleep, and the _both_locks context manager (pile.py:567-576) does the same for an unscoped region. The two locks therefore do exclude each other: an async method and a @synchronized method on the same Pile never run together, in one thread or across threads, and ADR-0002 D6 records the mechanism. @synchronized decorates 19 methods: __setitem__ (pile.py:286), pop (:294), include (:315), exclude (:321), clear (:335), update (:340), insert (:353), append (:357), get (:361), keys (:370), values (:374), items (:378), is_empty (:382), size (:386), __getitem__ (:406), __contains__ (:410), __len__ (:414), __bool__ (:419), and filter (:655). @async_synchronized decorates 8: asetitem (:578), apop (:587), aremove (:596), ainclude (:604), aexclude (:614), aclear (:622), aupdate (:626), and aget (:634). The undecorated helpers _setitem (:719), _get (:761), _pop (:794), and _insert (:838) are not lock sites of their own. Flow owns a third RLock for its own methods (lionagi/protocols/generic/flow.py:36-41, rebuilt at :136). What a per-method lock still cannot do is make a caller's read-then-act sequence atomic.
P5 — the holders are invisible in the type, and two sites await inside it. Session holds branches, MessageManager holds messages, Exchange holds flows, DataLogger holds logs, Executor holds its event pile, InMemoryStore holds its item Pile (lionagi/protocols/memory.py:53-82, shared across a session's branches at lionagi/session/session.py:100-103), and Flow holds its named views. Because coordination lives inside the type, none of those holders states what it protects or for how long. Two call sites hold a collection lock across an await: Session.asplit awaits Branch.aclone inside async with self.branches (lionagi/session/session.py:279-284), and that callee takes the message pile lock (lionagi/session/branch.py:557-559); Pile.adapt_to_async awaits the adapter inside the both-lock protocol (pile.py:914-915).
| Concern | Decision |
|---|---|
| Multiplicity | D1: multiplicity is a property of the ordering type; a unique order belongs to a collection and a repeated order is a standalone sequence. |
| Locking | D2: a collection type carries no lock; one holder at a time, an explicit snapshot when several reads must agree, and coordination where the holder lives. |
Out of scope. A new persistence format for either ordering type: the serialized Pile keeps its shape, a polymorphic item list plus a serialized order. A general cross-context linearizability or transaction contract: D2 removes the interior guarantee rather than strengthening it. Durable queue or scheduler semantics: a repeated order is an ordering, not an acknowledgement queue. Which holder serializes first when two holders share one collection: this record names the holder rule, and each holder's own record decides its schedule. The order in which the code changes land: ADR-0002's delta rows state the deliverables and their acceptance.
Decision¶
D1 — multiplicity is a property of the ordering type¶
A Progression's multiplicity is fixed by its type, and two types state the two rules.
The contract. Two ordering types, two rules:
| Type | Rule |
|---|---|
| Unique order (the order a collection installs) | Holds exactly the store's ids, one position each. Construction and every mutator reject a second position for an id that is already there. |
| Repeated order (a standalone sequence) | Holds every stored position. append, extend, insert, +=, and positional assignment store another position for an id already present. |
unique order
construct([uids]) -> rejects a supplied uid repeated in the sequence
append(uid) -> rejects a resident uid
extend([uids]) -> rejects a resident uid
insert(i, uid) -> rejects a resident uid
order[i] = uid -> rejects a resident uid
include(uid) -> set-like, no second position
exclude(uid) -> removes every occurrence
repeated order
construct([uids]) -> keeps every supplied uid, repeated ones included
append / extend / insert / += / order[i] = uid -> stores another position
include(uid) -> set-like, no second position
exclude(uid) -> removes every occurrence
The class names are the implementer's to choose; what this decision fixes is that the two rules are two types, that a collection's default order is the unique type, and that a caller holding one type is never handed the other by accident.
Exact semantics:
- The unique order holds exactly the collection's ids, one position each, as ADR-0002 D1 requires, and equality between the store's keys and the order is the type's invariant rather than the constructor's check.
- Both construction and every mutator of the unique order reject a second position for a resident id with the error the collection raises today (
pile.py:141-150for construction,:838-847and:719-759for the mutators); construction is named because it is the one path that stores a supplied sequence after checking only the collection's own keys. - The repeated order stores a second position for
append,extend,insert,+=, and positional assignment, and construction, membership, and serialization agree with that rule; this is the shippedProgressionbehavior (progression.py:186-188,:302-314,:364-369,:392-398,:379-381,:225-244). includeis set-like andexcluderemoves every occurrence on both types, so the two types differ only in what a position-storing mutator may do.- Pile construction copies the caller's order. When
orderis an order instance, the collection builds its own order from the supplied ids instead of adopting the instance. A caller that keeps the ordering it passed toPile(order=...)therefore cannot append a position to the collection's order. This is the target that closes P3, and it supersedes_validate_progressionreturning its argument (pile.py:135-137,:150). - Serialization follows the type: a unique order serializes as one position per id, a repeated order keeps repeated ids. Existing serialized standalone sequences already contain repeated ids and stay readable; the payload's class name is the ordering type's own change.
State of the code today. One Progression class carries both rules and applies them per method (P1); uniqueness holds only where the collection's own methods enforce it (P2); a supplied order is aliased rather than copied (P3). ADR-0002 delta row 3 carries the work, and ADR-0002 D4's superseding note records that D4's heading and its summary-table row are superseded.
Why this way. The alternatives — a uniqueness flag on one type, keeping the shipped mixed behavior, and a universal unique order — are compared below.
D2 — a collection carries no lock, and coordination belongs to the holder¶
A collection type owns no lock. A Pile or a Flow has one holder at a time; a holder that needs several reads to agree takes one snapshot and reads that; coordination between holders is the holder's, exercised where the holder lives.
The contract:
Pileexposes no lock. The private pair (pile.py:220-221), thelockandasync_lockproperties (:550-559), the both-lock context manager with its synchronous spin (:561-576), and the decorators that take them are removed, together with the async entry protocol that exists only to hold them (:869-887).- The snapshot value is a tuple of ids in order:
Pile.snapshot() -> tuple[UUID, ...], read once without a lock. It replaces the two informal order snapshots the code takes today (pile.py:390-398for iteration,:643-649for async iteration) and it is the value a caller keeps instead of holding the collection open. A caller that also needs items reads them throughPile.__getitem__on the ids, which is whatkeys,values, anditemsdo inside the lock today (:370-380). adump's critical sections become plain reads of the ids and the records it is already ordered by (:1006-1025).Flowdrops its RLock (flow.py:36-41,:136). Its methods —add_progression(:142-155),remove_progression(:163-175),get_progression(:177-206),add_item(:210-233),remove_item(:235-244),clear(:246-251) — become ordinary methods, andFlowis a holder of its views like any other holder.- The holder rule. The object that owns a collection serializes access to it; the collection type does not, and a caller does not hold the collection across an await. The named holders are:
Sessionoverbranches(lionagi/session/session.py:74-76,:279-284);MessageManagerovermessages(lionagi/protocols/messages/manager.py:107-109,:111-117);Exchangeoverflows(lionagi/session/exchange.py:87-110);DataLoggeroverlogs(lionagi/protocols/generic/log.py:244-247,:291-325);Executorover its event pile and pending order (lionagi/protocols/generic/processor.py:197-202);InMemoryStoreover its itemPile(memory.py:53-82), which owns no lock today and relies onPile.ainclude/aget; under D2 it takes its own lock forstoreandretrieve;Flowover its named views (flow.py:142-251).- Four of those sites are synchronous holders, and two sites genuinely wait.
Session'sainclude_branches(session.py:74-76) takes the lock and calls the synchronousinclude_branches;MessageManager.aclear_messages(manager.py:107-109) takes it and calls the synchronousclear_messages;Exchange.collect(exchange.py:87-110) pops from the outbox inside the lock and runs itsawait gatherat:114after the lock is released;DataLogger.alog(lionagi/protocols/generic/log.py:244-247) takes it and calls the synchronouslog. These are holders of one collection for the duration of a synchronous call, not waiters. The sites that do await while holding a collection lock areSession.asplit(session.py:279-284), whose calleeBranch.acloneadditionally takes the message pile lock (branch.py:557-559), andPile.adapt_to_async(pile.py:914-915). Both move onto the holder rule or onto the snapshot, as doesDependencyAwareExecutor._preallocate_all_branches(lionagi/operations/flow.py:345-363), which holdsbranches.async_lockaround synchronous work today. - Accepted records this changes.
- ADR-0006 D4 (
Accepted) is the message-added observer contract. Its async path reads "lock pile → construct and insert or replace message → release pile lock → snapshot callbacks" (docs/adr/ADR-0006-conversational-message-envelope-and-ordered-history.md:357-363), which isMessageManager.a_add_messagetoday (manager.py:111-134). The lock in that path is the pile's, and its holderMessageManageris listed with the holders above and keeps a lock of its own around the insertion instead of taking the pile's. ADR-0006 D4's re-entrancy statement — callbacks run after the lock is released, so an async callback may re-entera_add_messagewithout deadlocking (:379-381) — keeps its observable behavior, because callbacks still run after the insertion, and restates its reason as the holder's critical section ending before the callback snapshot. - ADR-0092 D3 (
Accepted) keepsInMemoryStoreon aPileand records thatPile.ainclude()andaget()supply the synchronization (docs/adr/ADR-0092-minimal-branch-and-session-memory-store.md:261-264). With no Pile lock,InMemoryStoreis the holder of thatPileand takes its own lock forstoreandretrieve, the same treatment asMessageManager. - ADR-0004 D5 (
Accepted) presumes the Pile locks: its backing Piles "have their own locks, but direct Pile mutation is not coordinated with the Graph lock" and strengthening it "requires closing direct mutation paths and coordinating Graph and Pile locks" (docs/adr/ADR-0004-directed-graph-structural-invariants.md:341-342,:369-370). With no Pile locks, Graph's own RLock is the only lock, and traversal stability belongs to Graph's own record. - Every named view is one of the two types. With multiplicity fixed by D1, the views the collection records name classify as follows.
| View | Where the code is | Class |
|---|---|---|
A collection's default order (Pile.progression) | pile.py:200-203; uniqueness enforced today at :141-150, :315-319, :838-847, :719-759 | unique |
Flow views appended by add_item | flow.py:210-233; ADR-0002 D5 records that repeated calls append the same id repeatedly | repeated |
Executor.pending | processor.py:197, :238 (pending.include(event)) | repeated type, used set-like: this call site stores at most one position per id |
Executor.pile | processor.py:199-202 | unique |
MessageManager.messages | manager.py:79-84 | unique |
MessageManager typed views (assistant_responses, actions, action_requests, action_responses, instructions) | manager.py:486-523 | unique: each access builds a fresh filtered Pile (pile.py:1027-1065) whose own default order is a unique order |
Branch.progression, both forms | branch.py:254-261: the metadata["current_progression"] subset, built at lionagi/tools/context/context.py:164-171, or MessageManager.progression when it is unset | unique: the subset is seeded from the full order and the context tool's restore path skips host copies and ids already in its proposed view (context.py:443-444) before inserting an original (:462-463), the branch appends a newly added message to it only when the id is absent (branch.py:431-432), and after the message manager replaces the system message or clears its messages (manager.py:104-105, :456-457) the branch drops every id no longer in the pile and puts the system message at its head once (branch.py:445-450), branch.py:256 calls it a subset, and ADR-0007 D2 rejects a duplicate id in the selected history (docs/adr/ADR-0007-canonical-turn-request-compilation-boundary.md:242-243) |
Exchange mailbox views outbox and inbox_<sender> | exchange.py:53, :148-153 | repeated: standalone orders held by per-owner Flows; the progressions Pile that indexes them is itself a collection, and therefore unique over the view objects |
- ADR-0003 D5's
pending(the set-likeincludein "Executor owns live Events and pending order",docs/adr/ADR-0003-in-process-event-execution-lifecycle.md:358-359) is theExecutor.pendingrow above.
Why this way. The alternatives — keeping the two locks with a published contract, and one lock for both contexts — are compared below.
Consequences¶
- A caller states multiplicity in the ordering type it holds, so the rule is read from the signature rather than reconstructed from the call history, and a collection's default order is unique because of its type rather than because its constructor checked.
- Copying the caller's order on construction closes the alias in P3 at the cost of one copy per construction from an existing order; construction from a plain sequence is unchanged in shape.
- Removing the interior locks removes a deadlock class and the cross-thread spin from every async entry, and it gives up the per-method mutual exclusion ADR-0002 D6 records; that exclusion never spanned a caller's read-then-act sequence, so nothing a caller could already rely on across an await is lost. Each named holder moves onto the holder rule or onto the snapshot, which is work in the callers rather than a change to the ordering types.
- A snapshot copies the ids and nothing else, so a reader that takes several snapshots pays for each and a holder that owns the collection pays nothing extra; a reader that needs the items as well does its own lookups on the ids, which can miss an item removed after the snapshot.
- Reversal is asymmetric. Splitting the ordering type touches serialized repeated sequences and the construction path; restoring interior locking touches every named holder.
- Neither decision adds a persistence format. The serialized Pile keeps its shape, a polymorphic item list plus a serialized order, and only the order's class name inside that payload changes when the one ordering type becomes two.
Alternatives considered¶
A uniqueness flag on one ordering type¶
A unique: bool field, or a constructor switch, would carry the rule in a value, and every caller and reader would have to read that value to know whether a mutation may duplicate an id. That is the method-dependent rule moved from the method name to a field, and the field is what the type cannot check: nothing prevents flipping it on an order a collection already holds. It lost for that reason.
Keep the shipped mixed behavior¶
The collection's constructor and its naming mutators would stay the only places the unique rule holds, and a standalone order would keep meaning different things per method. It lost because the invariant then depends on every call site enforcing it, and a caller holding the order directly can break it, so the same ground has to be argued again for each new mutator. ADR-0002 delta row 3 carries the split.
A unique order everywhere¶
A universal set-like order would simplify membership and align standalone sequences with collections. It would remove the ability to represent repeated sequence visits and would change append, extend, insert, and already serialized data. It lost because the repeated order is meaning-bearing where it is used: Flow views take the same UUID again on every add_item (flow.py:230-233, ADR-0002 D5), and mailbox orders carry one entry per delivery.
Keep the two locks and publish their contract¶
A published lock contract would document what the type does today. It cannot give a caller a linearizable mixed thread/async view or a stable snapshot, because each lock is held for the duration of one method, so a caller's read-then-act sequence spans several acquisitions and the two locks, which do exclude each other, protect no more than one method at a time. Documenting the limit does not remove it, and the cost of holding a collection across real work stays hidden inside the type.
One lock for both contexts¶
One lock would have to serve a synchronous and an asynchronous caller at once. A threading lock cannot be awaited as the only async primitive, and an AnyIO lock is not a drop-in synchronous lock, so neither replaces the pair on its own; and a lock held for the duration of one method still cannot make a caller's read-then-act sequence atomic. Removing the pair is the decision taken instead of widening it.
Notes¶
ADR-0002D4 and D6 keep the shipped-state descriptions, with superseding notes that point here. ADR-0002's delta rows 3 and 4 carry the two code deliverables and their acceptance clauses, so this record decides and the retrospective record tracks.