Skip to content

Use case

Items into containers

Scan bags into a bin until it is full or the window closes. Both rows change or neither, and the workflow wakes once.

Surfaces: Scan · Dashboard

The moment

The outbound dock, mid-afternoon. Bags for each destination converge on one bin. A bin ships when it holds twelve bags, or when the 4 p.m. truck leaves, whichever comes first. The person at the bench has a tablet, a scanner on a lanyard and a badge on their belt.

They pick up a bag and scan it. The screen fills with one word in very large type: bin-7. They walk over, scan the bin’s label, and drop the bag in. The screen says “Drop it in bin-7. All good.” Two seconds of work, and three records changed: the bag knows its bin, the bin knows its bag, and the count moved from 6 to 7.

If they had scanned bin-9 by mistake, the screen would say so, name the right bin, and write nothing at all.

The container is a row

A bin is one escalation row that fills over time: an open accumulator. Items join it one at a time while it stays pending. The workflow resumes once, with the ordered collection, when the bin is full, when its window closes, or when someone closes it by hand.

workflows/bin.ts
import { conditionalAccumulator, type LTEnvelope } from '@hotmeshio/long-tail';
type BagV1 = { weight?: number };
export async function rollupBin(envelope: LTEnvelope) {
const { binKey, max = 12, timeout = '4h' } = envelope.data;
const bin = await conditionalAccumulator<BagV1, { shippedBy?: string }>(`bin-${binKey}`, {
role: 'bin',
type: 'rollup',
subtype: 'bin',
description: 'Scan each bag into the bin. It ships when full or when the window closes.',
metadata: { binKey },
accumulate: { max },
timeout,
});
if (bin === null) return { data: { binKey, shipped: false, reason: 'cancelled' } };
return {
data: {
binKey,
trigger: bin.$trigger,
bags: bin.$accumulated.map((item) => item.itemKey),
totalWeight: bin.$accumulated.reduce((sum, item) => sum + (item.payload?.weight ?? 0), 0),
},
};
}

While the bin fills, its progress is on the row as queryable facets: accumulate_count (bags held now), accumulate_max (the trigger) and accumulate_keys (which bags). A list schema on the bin role can show every open bin with its fill level, read from those facets.

Each bag waits for its bin

A bag is a row too: its own accumulator of one. Its workflow waits to be placed.

workflows/bag.ts
import { conditionalAccumulator, type LTEnvelope } from '@hotmeshio/long-tail';
export async function rollupMember(envelope: LTEnvelope) {
const { orderId, binKey } = envelope.data;
const placement = await conditionalAccumulator(`bag-${orderId}`, {
role: 'bag',
type: 'rollup',
subtype: 'bag',
description: 'Waiting to be scanned into a bin.',
metadata: { orderId, binKey },
accumulate: { max: 1 },
timeout: '24h',
});
const bin = placement?.$accumulated[0];
return { data: { orderId, placed: !!bin, binEscalationId: bin?.itemKey ?? null, placedAt: bin?.at ?? null } };
}

The reciprocal add

Placing a bag is one call that writes both rows. The bin gains the bag under its order id. The bag gains the bin’s id. Each entry records the other row’s id.

await lt.escalations.accumulate({
id: binEscalationId,
itemKey: 'A-1042',
payload: { weight: 2 },
reciprocal: { id: bagEscalationId },
});
// { outcome: 'accepted', count: 7, remaining: 5, escalationId,
// reciprocal: { outcome: 'completed', count: 1, escalationId, signaled: true } }

The bag’s accumulator of one is now full, so its workflow wakes with the bin’s id in the same statement. The add that brings the bin to twelve completes the bin and wakes its workflow the same way.

The bench motion

The scene at the top is three scans with nothing in between: the bag, your badge, the bin. The bag scan says which item. The badge says who. The bin scan is the act.

Scan Code What happens
Bag 12:0:<orderId> The station holds the bag and shows its bin, large
Badge 11:0:<badge> The station knows who is working. The badge can come first, too
Bin 13:0:<binKey> The bag joins the bin, and its own row completes

Each scan code is version : category : target. The version picks the scheme: what the target identifies (orderId for bags, binKey for bins). The category picks the rule: what scanning does. The bag rule holds the item:

12:0, bag label
{ "query": { "roles": ["bag"], "status": "pending" }, "verb": "hold",
"params": { "hold": { "ttlSeconds": 300, "label": "{scan.target}", "headline": "{item.binKey}",
"expect": { "schemes": [13], "prompt": "Walk to this bin and scan it." } } } }

The bin rule places the held bag, after checking that the pairing is right:

13:0, bin label
{ "verb": "accumulate", "requireActingIdentity": true,
"subject": { "schemes": [12] },
"match": { "target": ["{subject.binKey}"] },
"refuse": { "markdown": "That's not this bag's bin. It goes in **{subject.binKey}**." },
"done": { "markdown": "Drop it in **{container.binKey}**. All good." },
"params": { "itemKey": "{subject.orderId}",
"accumulate": { "from": "subject", "container": { "roles": ["bin"], "subtypes": ["bin"] } } } }

match runs before anything is written, so a wrong bin is refused with the right one named. container keeps the pick on bin rows, never on another bag that shares the binKey. The held bag row is written as the reciprocal, in the same statement as the bin.

The badge attributes, never grants

The tablet signs in once as a station account with read access to the queues it fronts and no write access of its own. Every act takes a badge. A badge scan mints a short-lived grant, and the scans that follow run as the badged person, under their own live permissions. The bin records who placed each bag (the entry’s actor), and the scan stamps which station did it.

A role sets its stations’ badge policy in properties.badge_grant: one act per badge scan at a workstation, or ten minutes of acts at a busy placing bench. A badge never adds permission. A person who may not place bags cannot place bags at any station.

A trusted service that adds an item because a person acted somewhere else passes that person’s id as initiatedBy. The entry credits the person, and the service’s own rights still gate the write.

The window is a delivery, not a failure

At 4 p.m. the truck leaves whether bin-7 is full or not. The timer fires, and the workflow receives the bags that made it, in order:

What ended the wait $trigger Row status The workflow receives
The add that reached max count resolved The full collection
The timeout timeout expired The collection so far
A person closed it by hand resolve resolved The collection plus their payload
Cancel none cancelled null, with the items kept on the row

Every path except cancel delivers the collection. What the workflow does with a partial bin is its policy: ship it, or open a new bin for the next truck.

What is in it, and what is coming

Whoever packs a bin asks two questions every time: what is in it now, and what is still on its way? The bag’s facets answer the first from either side. To find the bin holding order A-1042, query the containers:

Terminal window
curl -G https://lt.acme.dev/api/escalations \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'facets={"accumulate_keys":["A-1042"]}'

The container role’s list schema answers the second. A list column can count each bin’s held items, and another can count the rows still pending at the upstream stations for the same destination, so the bin list reads “Holds 7 · Coming 3” from the queue alone.