../writing

Explanation adr

TaskBoard Printer Integration: How a Move Becomes a Receipt

demonstrates: An architecture-decision explanation of why a physical print is modeled as a persisted-first, best-effort side effect of a domain event, and how one formatter interface drives both a local background task and a pull-based cloud agent.

2026-07-18 · explanationadrtaskboardfastapipython-escposaws-lambdacognitowindows

The signature feature of TaskBoard: create or move a task and a receipt slides out of a Rongta RP850 thermal printer. This page explains how that works and, more importantly, why it is built the way it is. For the endpoints and payloads themselves, see the API reference.

Printing is a side effect, not an endpoint

There is no “print this” call in the API. Printing is wired to two events, and only two:

EventEndpointReceipt
Task createdPOST /tasksNEW TASK
Task moved to another swimlanePOST /tasks/{id}/moveMOVED, or COMPLETED when the destination is a “done” column

Editing a task or adding a comment prints nothing, and a move that keeps a task in its current column prints nothing either. This is deliberate: a receipt is a notification about a change that already happened. Each route validates and persists the change first, commits, and only then dispatches the print. So the paper always reflects committed state, and a task that reached a column named Complete, Completed, or Done has already had its progress snapped to 100 before the COMPLETED receipt is even formatted.

One formatter, two backends

Everything a receipt needs is a trio of calls: .set() for alignment and weight, .text() for a line, and .cut() to release the slip. The formatter in printing.py writes against that minimal interface, so the same code drives either backend:

  • PRINT_MODE=printer: a real python-escpos device, opened by reusing a sibling rp850-printer project’s get_printer(name). The API imports that function directly rather than shelling out or making an HTTP hop, so there is exactly one process, and one place, that talks to the hardware.
  • PRINT_MODE=console: a ConsolePrinter shim that collects the same writes and renders the card to the server log, boxed like a paper slip.

That shim is why the whole app is developable and demoable with no hardware attached: you work in console mode and flip a single environment variable to print for real. A console-mode MOVED card looks like this:

+--------------------------+
| MOVED                    |
| Mon Jul 6 8:07pm         |
| ======================== |
| TASK                     |
|   Verify RP850 over HTTP |
| BOARD                    |
|   DevOps Certs           |
| MOVE                     |
|   Pending -> In Progress |
| PROGRESS                 |
|   [................]  0% |
| ======================== |
+--------------------------+

Two delivery modes: direct and queue

The formatter answers how to draw a receipt. A separate question is how the receipt reaches the printer, and the answer depends on where the API is running. TaskBoard has two delivery modes, chosen by PRINT_DELIVERY.

direct (local-first, the default). The API runs on the PC the printer is attached to. On create or move it schedules the print as a FastAPI BackgroundTask, which runs after the HTTP response is sent. It writes no database rows; it just prints. This is the simplest possible path and the right one when the API and the printer share a machine.

queue (cloud-hosted). When the API runs in AWS Lambda inside a private VPC, it cannot reach a printer in my apartment, and it cannot rely on a background task surviving after the response in a serverless runtime. So instead of printing, the create/move routes write a PrintJob row synchronously. A small home print-agent running on the PC does the rest:

API (queue mode) ── enqueues ──►  PrintJob rows
                                        ▲  │  GET /print-jobs?status=pending
                                        │  ▼
                          POST /ack ── home print-agent ── prints on the RP850

The agent polls GET /print-jobs?status=pending, prints each receipt with the same card layout (a mirrored formatter, agent/receipts.py, deployed alongside the agent), and then POST /print-jobs/{id}/acks it so it is not printed again. The PrintJob row is deliberately denormalized (it stores board and column names, not foreign keys), so a receipt still prints correctly even if its task or board is deleted in the meantime.

Both modes are the same feature (a receipt on state change) with the transport swapped to fit where the API lives.

Best-effort by design

In both delivery modes, printing is wrapped so it can never take down a task change:

  • In direct mode the background worker runs inside a try/except; a slow or offline printer, or a missing driver, logs an exception and the task change still stands.
  • In queue mode a failed print leaves the PrintJob at pending, so the agent simply retries it on the next poll, and network blips between the agent and the API are logged without stopping the loop.

The invariant is the same either way: a create or a move returns the same HTTP response with the RP850 plugged in, unplugged, or still in its box.

Authenticating the agent in the cloud

Against the local, no-auth API the agent calls the endpoints unauthenticated. Against the cloud API, every business route sits behind a Cognito authorizer, so the agent presents a Cognito machine-to-machine bearer token obtained through the client-credentials grant and cached until shortly before it expires. It is a service identity, distinct from the human user tokens the web and mobile clients carry, scoped to the job queue.

The gotcha: finalize every job

The bug worth remembering came from the Windows spooler. python-escpos’s Win32Raw device prints through it: opening the device starts a spool document (StartDocPrinter), .text() writes into it, and .close() ends the document (EndDocPrinter). Ending the document is what actually releases the receipt to the paper.

A standalone script gets this for free, because the process exits after printing and that finalizes the job. A long-running server does not. Early on, each event opened a fresh printer and never closed it, so every receipt sat buffered in the spooler until some later job happened to flush it. The visible symptom was moves that appeared to “pile up” and print several moves late.

The fix is one line of discipline: the emit helper writes each card inside a try/finally that always calls printer.close(), so every event finalizes its own job and prints immediately.

Where the code lives

This piece describes the finished system, so it spans the version series. The direct-mode path (the printing.py formatter and the PRINT_MODE printer/console shim) lives in the public version repos, for example taskboard-desktop. The queue delivery mode, the pull-based home print-agent (agent/receipts.py), and the machine-to-machine cloud authentication build on that foundation in the cloud edition, whose code is in a private repository.

See also