Reciprocal add
A bag joins a bin and the bin records the bag in one statement. Both rows change or neither.
The situation
A bag goes into a bin. The bin needs to know it holds the bag, and the bag’s own workflow needs to know which bin it went into. Two writes that can half-fail would leave a bag in a bin that does not list it.
The recipe
const placed = await conditionalAccumulator<unknown>(`bag-${orderId}`, { role: 'bag', metadata: { orderId, binKey }, accumulate: { max: 1 },});const res = await lt.escalations.accumulate({ id: binId, itemKey: orderId, reciprocal: { signalKey: `bag-${orderId}` },});// { outcome: 'accepted', count: 4, remaining: 8, escalationId,// reciprocal: { outcome: 'completed', count: 1, escalationId, signaled: true } }The bag waits as its own accumulator with max: 1. The add names it as the reciprocal by id, signalKey, or key and value. The bin gains the order; the bag gains the bin’s id as its item key and wakes.
The guarantee
Both rows are locked in one ordered statement and both guards run before either write. Each entry carries the other row’s id as reciprocalId. If the bag’s side blocks (already placed, full, terminal) the answer names why, such as reciprocal-duplicate, and the bin is untouched. Removal with a reciprocal removes from both or neither. See Batches and accumulators.
Related patterns
The escalation boundary
Open container
One row is a container that fills over time. Items join one at a time and the workflow wakes once with the ordered collection.
The escalation boundary
Find a container by held key
Which bin holds order ORD-9 is one GIN-indexed containment query on the bin rows. No side table tracks membership.
The queue as a market
Lot traceability by containment
Stamp the lots each unit consumed onto its row as an array facet. Every row that used a lot is then one containment query.
The physical world
Item, badge, container
Three scans place an item in a container: the item says which, the badge says who, and the container scan writes both rows at once.
