Blog
Machines resolve rows too
Factories have kept people and machines in separate systems that meet at a screen. When a machine resolves the same row a person would, one workflow can run a line of both and never tell them apart.
October 23, 2026 · Long Tail team
Walk a production line and count the systems. The machines talk to a controller in their own protocol. The controller feeds an MES that knows about jobs and recipes. The people work from a different tool, a ticket queue or a spreadsheet or a whiteboard, that knows about tasks. Somewhere a person stands between the two, reading one screen and typing into the other.
When something goes wrong at the boundary, a jam, a refused recipe, a unit finished as a reject, the problem falls into the gap between systems. The machine knows. The MES finds out later. The person who has to decide what to do finds out last, usually by walking past.
We built Long Tail around one idea: a workflow states what it needs, and whoever can provide it resolves a row. We had already applied that to people on dashboards, services over HTTP, operators in a terminal and agents over MCP. Machines were the obvious next resolver, and the test of whether the idea was real.
The same row
Here is a hop on a line, from a cut cell to a bond cell. The workflow parks a handoff row on the receiving station’s role and waits:
const hop = await conditionalAccumulator(`route-${routeKey}-hop-2-a1`, { role: 'bond-cell', type: 'handoff', description: 'U1, U2: cut-01 → bond-01', metadata: { siteId: 'site-1', lineId: 'line-1', units: [{ unitId: 'U1', carrierId: 'C-7' }, { unitId: 'U2', carrierId: 'C-8' }], jobId: `${routeKey}-bond-01`, senderJobId: `${routeKey}-cut-01`, fromMachine: 'cut-01', toMachine: 'bond-01', recipeId: 'RCP-BOND-A', recipeVersion: '2', process: 'bond', attempt: 1, }, accumulate: {}, timeout: '30m',});Nothing in it says “machine”. It says what moves, from where, to where, with which recipe, and by when. If bond-cell were a room of people with glue guns, the row would be the same, and someone would resolve it on the dashboard.
Because bond-cell is a machine’s role, a gateway running inside Long Tail does what a person would do, logged in as the machine. It sees the new row and dispatches the job over MQTT. When the machine accepts, the gateway claims the row as bond-01. As the cut cell releases each unit and the bond cell reports each arrival, it adds them to the row as items. When every unit has arrived, it resolves the row as the receiver:
{ received: true, receiver: 'bond-01', units: ['U1', 'U2'] }The workflow resumes with that payload. Every machine is a bot account on its role, so claimed_by and resolved_by name the machine, the same columns that name a person on any other row.
What falls out
The interesting consequences are the ones we did not have to build.
Mixed routes are free. A route is a list of stations, and each hop parks a row on the next station’s role. Make one station a person and the same workflow sends that hop to a dashboard. Our example runs an automated route and a manual one on the same line, chosen per order, and the workflow code does not branch on which.
Access control is the role. Dispatching a job to a machine means parking a row on its role. So does commanding it. Whoever may park rows on that role may drive that machine, and nobody else may. There is no second permission model for the floor.
Failures become decisions. When a hop ends without every unit arriving, the row resolves with received: false and a reason: refused, no reply, jam, carrier mismatch, unreadable id, or a unit the sender finished as a reject. The platform reports the facts and does not choose. The workflow parks a row on the line operator’s role listing the choices that fit. After a jam the units are still on the line, so retry, manual and scrap all fit. After a reject they are off the line for good, so retry is not offered. The operator decides on a tablet, and the workflow continues. A retry is a new job with a new id, so nothing meant for the failed attempt can run later.
Commands are rows. To change over a machine to a new recipe, park a command row on its role and wait, the same way you would ask a person:
const reply = await conditional<Machines.CommandResolution>(`changeover-${orderId}`, { ...Machines.commandRow(Machines.commandTarget(line, 'bond-01'), 'changeover', { recipe: { id: 'RCP-BOND-A', version: '2' } }), timeout: '10m',});The gateway sends the command and resolves the row with the machine’s reply: accepted, refused with a reason, or no reply. Each command carries an id and an expiry, so a repeated command is answered rather than run twice, and a command that arrives late is refused rather than run at the wrong moment.
The floor raises its own rows. A stopping alarm becomes a row on the operator role and resolves when the machine clears it. A machine that drops offline, or goes quiet while still connected, gets a row that resolves when it is heard again. A message that breaks the contract under a strict codec is held for a person and never acted on. Everything that needs a human lands in the same queue as everything else that needs a human.
Traceability is a facet. When a sender finishes a unit, the gateway merges the unit’s materials and lots into the handoff row in the same statement. “Which rows consumed lot LOT-7?” is one containment query. A unit’s whole path is every handoff row whose units holds it.
Rows are the authority
An event bus is fast and useful, and it is a bad place to keep the truth. The gateway treats escalation events only as a hint about which row to look at. It reads the row back and acts on the stored role, status and metadata. An event that the stored row contradicts does nothing.
The same rule makes the gateway safe to run on many replicas. One replica leads each line, holding a Postgres advisory lock on its own connection. When it goes away, another takes the lock and reads every open row back before it sends anything: open handoffs and the units that already arrived, commands in flight, alarms, offline machines. Pacing survives too. A machine with a queue depth of four is sent at most four jobs, and the extra handoffs wait, unclaimed, in its role queue, the way work waits for a busy person.
The wire is a translation
Real machines speak many dialects. The gateway works in one generic line protocol over MQTT, and a codec translates: which topics a machine publishes on, how to decode its events, and how to encode a command it understands. A codec can be strict, sending broken messages to a person instead of guessing. It can infer a receipt from a different event when a machine never reports arrival. If a machine has no message for a command, the codec says so, and the command row resolves unsupported with nothing sent.
Machines connect with their own client certificates, and the broker’s access list, rendered from the line definition and the codec, lets each one write only its own channels and read only its own commands. A line can learn its machines’ addresses, binding each station to the first machine that claims it, and a second claimant raises a row for a person to approve.
Why it matters
The point is not that Long Tail speaks MQTT. Plenty of things speak MQTT. The point is that the gap between the machine’s world and the people’s world closes when both resolve the same row.
A supervisor looking at the bond station sees one queue: handoffs the machine is working, a changeover waiting for a reply, a jam the operator has not decided yet. The history of a unit is a sequence of rows, with a machine’s name on some and a person’s on others, in one table, under one set of permissions. The workflow that runs the line reads like the process it models, because it does not need to know who did each step.
The machines guide is the full contract for a host. The factory line use case walks through a four-station line from jam to retry.