At ShitOps we treat every passenger receipt as a first-class billing event. Our Self-driving cars each carry an onboard thermal printer, and when a trip ends the printer emits a receipt that must reconcile into SAP before the customer leaves the curb. We recently discovered that jammed printers were creating silent revenue leaks: the vehicle thought the receipt printed, SAP never received the line item, and support only found out when a passenger posted a photo in discord. This post explains the event-driven AI printer-observability fabric we rolled out to make every print job traceable, billable, and self-healing.
The Incident That Started It All¶
During a rainy Tuesday rollout, three vehicles in the same geo-cell reported successful print jobs while their paper trays were empty. The rclcpp printer driver kept publishing a cached ok state, the SAP adapter retried for 45 minutes, and the only visible symptom was a spike in support messages. We needed a design that could prove what happened to a single receipt across the vehicle, the message bus, the billing boundary, and the monitoring layer. We also needed a feedback loop that could open a discord thread for the on-call operator without anyone copying IDs by hand.
Architecture Overview¶
We model each printer as an autonomous edge service. A rclcpp node on the vehicle owns the printer State machine, publishes MQTT events, and annotates every transition with Jaeger trace context. A regional MQTT broker fans events into an AI confidence service, which scores whether a print job is likely complete. Confident jobs flow to SAP; suspicious jobs flow to discord and icinga2. Static ips are assigned per vehicle subnet so the broker ACLs and firewall rules stay deterministic.
Edge Node: rclcpp and the Printer State machine¶
Each vehicle runs a rclcpp component named printer_sentinel. It subscribes to the printer device topic, wraps the vendor SDK, and drives a State machine with states for Idle, Printing, Inspecting, Billing, and Faulted. The node publishes MQTT messages on mqtt/printers/{vin}/state and mqtt/printers/{vin}/receipt. Every transition includes a Jaeger span ID, the current paper level, the firmware checksum, and the last known printer error code. If the State machine sees three identical fault transitions inside 90 seconds, it publishes a quarantine event and waits for the AI service to decide whether the receipt should be replayed. This gives us a crisp audit trail without asking the vehicle to understand SAP.
MQTT Topic Topology and IP Plan¶
The MQTT hierarchy is deliberately explicit: mqtt/fleet/{region}/{vin}/printer/state, mqtt/fleet/{region}/{vin}/printer/receipt, and mqtt/fleet/{region}/{vin}/printer/decision. Vehicles get static ips from a per-region pool, and the broker enforces client certificates plus topic ACLs by IP range. A bridge worker consumes receipt events, enriches them with the vehicle geofence, and forwards them to the AI scorer. We keep a replay topic for receipts that were quarantined, so an operator can republish the original MQTT payload after a paper refill. The IP plan also lets icinga2 probe broker health from inside each region without exposing the cluster to the public internet.
AI Confidence and Jaeger Traces¶
The AI scorer is a small gradient-boosted model that reads receipt length, print duration, paper-level delta, printer firmware, and the last 20 MQTT events for that vehicle. It returns a confidence score from 0 to 1. Anything above 0.93 is considered billable, anything below 0.80 is sent to discord for human review, and the middle band triggers a second opinion from a rules engine. Jaeger captures every hop: rclcpp transition, MQTT publish, bridge consume, AI inference, and SAP call. When a receipt takes longer than 12 seconds end-to-end, the trace is tagged as a billing-latency outlier. This is how we finally connected a paper jam in one vehicle to a delayed invoice in another region.
SAP, discord, and icinga2 Feedback Loop¶
The SAP adapter is idempotent by receipt ID. It posts a billing document, stores the SAP document number back on the MQTT decision topic, and emits a completion span to Jaeger. If SAP rejects the payload, the adapter opens a discord thread with the trace ID, the vehicle VIN, the printer serial, and the exact SAP error. The discord bot includes buttons for refill, replay, and escalate. icinga2 watches the whole pipeline with passive checks: broker lag, AI p95 latency, SAP 5xx rate, and unresolved discord threads. A failed check creates a second incident in icinga2 with a link to the same Jaeger trace, which keeps the on-call rotation aligned with the billing team.
Rollout and Results¶
We rolled the fabric out region by region behind a feature flag. In the first two weeks, the State machine quarantined 1,284 receipts, the AI scorer recovered 917 of them automatically, and the discord bot closed 301 threads after a paper refill. SAP reconciliation dropped from hours to seconds for the happy path, and icinga2 caught two broker partitions before they became customer-visible. The best part is that every receipt now has a Jaeger trace, a discord audit trail, and a SAP document number. We are already planning a second phase that streams printer telemetry into the same rclcpp graph used by our Self-driving cars perception stack, because a unified event model is clearly the right foundation for receipt reliability at fleet scale.
Comments 12
Praxis McPragmatic
This is a genuinely impressive audit trail, but I have to ask: why does a self-driving car need to print a physical receipt at all? Every other fleet I've worked with just emails the receipt or pushes it to the app. You've built an AI confidence scorer, a Jaeger tracing fabric, and a discord bot to keep a thermal printer alive at the curb. That's an enormous amount of engineering to defend a technology from 1998. That said, the quarantine-and-replay design is legitimately clever. The three-faults-in-90-seconds rule with an audit event is the kind of pattern that would help in a lot of IoT systems.
Baroness YAML von Trace Author
Fair challenge, and honestly we debated it. The regulatory picture in two of our markets currently requires a physical receipt for certain fare classes, and rewriting the compliance story was slower than making the printer observable. So we treated it as a billing boundary, not a product feature. Our longer-term plan is to make the receipt a logical artifact and treat the printer as one of several possible sinks. That's exactly why we're streaming printer telemetry into the same rclcpp graph as perception in phase two. Once the event model is unified, dropping the printer becomes a config change instead of a rewrite.
Anika Realtime
I kind of agree with Praxis but I think the point is subtler. The printer is almost incidental here; what they've really built is a generic "did this side effect actually happen" framework. Swap thermal printer for payment terminal or door lock and the same state machine applies. I'd love to see that abstraction named explicitly in the post.
Marcus PubSub
The MQTT topology section raised my eyebrows a bit. You've got mqtt/fleet/{region}/{vin}/printer/state, receipt, decision, and a replay topic — are you doing QoS 1 with clean sessions, or persistent sessions and QoS 2 for the receipt path? For billing events, at-least-once delivery plus idempotency at the SAP adapter is a reasonable call, but I'd like to see the reasoning spelled out, especially since you mention the adapter is idempotent by receipt ID. Also: what happens to the replay topic if the bridge worker is down for an hour? Do quarantined receipts have a TTL, or can an operator replay something from three days ago?
Baroness YAML von Trace Author
Great questions. We run QoS 1 on state and decision, but QoS 2 on receipt specifically because the receipt topic is the one that crosses the billing boundary. Sessions are persistent for the bridge worker so it can catch up after a partition, which is also how icinga2 caught the two broker partitions mentioned in the rollout section. Quarantined receipts currently have a 72-hour TTL enforced by the replay worker. Anything older requires an explicit escalation because we don't want receipts reconciled into the wrong billing period. That policy is enforced in config rather than in code, which has already saved us once.
Marcus PubSub
That makes sense, thanks for the detail. The 72-hour TTL tied to billing periods is a nice touch — I've seen reconciliation jobs silently cross period boundaries and create genuine accounting problems. Would love a follow-up post on the replay worker itself.
Yolanda SRE
The icinga2 passive checks are the part I want to hear more about. You list broker lag, AI p95 latency, SAP 5xx rate, and unresolved discord threads. How are you avoiding alert fatigue when the discord bot opens threads faster than on-call can triage them? 301 threads closed after a refill in two weeks sounds great for the summary, but in week one that could have been a wall of noise. Also, a static IP per vehicle subnet is clean for ACLs, but what's your story for certificate rotation? A compromised client cert with a static IP is a very tidy attack surface.
Baroness YAML von Trace Author
On alert fatigue: unresolved discord threads feed icinga2 as an aggregated check, not per-thread. A single alert fires when the unresolved thread count for a region crosses a threshold, and the check message links to the filtered discord view rather than opening a new incident per receipt. That kept the on-call rotation sane during the first rollout wave. On certs: fair point, and it's the weakest part of the current design. We're on 90-day rotation with automated issuance, but static IP plus long-lived certs is a coupling we're actively unwinding. Phase two moves to short-lived certs with identity derived from the vehicle attestation rather than the subnet. I'd rather flag that openly than pretend it's solved.
Dmitri Traces
The Jaeger section is the strongest part of the post. Connecting a paper jam in one vehicle to a delayed invoice in another region is exactly the kind of cross-boundary story most observability posts hand-wave. The 12-second billing-latency outlier tag is a good concrete heuristic. One thing I'd push on: the AI scorer reads the last 20 MQTT events for a vehicle. That's a nice feature set, but it means the model's input depends on a mutable event stream. If a replay republishes an old receipt, does the scorer see the same 20 events it saw the first time, or the current 20? If it's the latter, your confidence score isn't reproducible and your audit trail has a hole in it.
Baroness YAML von Trace Author
This is a genuinely sharp catch and you're right to call it out. In the current implementation the scorer reads the current window, which means replay can produce a different confidence score than the original evaluation. We handle that by storing the original score and feature snapshot on the decision event, so the replay path reuses the snapshot rather than re-scoring. That preserves reproducibility for audits, but it means the model never gets a second look at a receipt it misjudged. We're discussing a deliberate re-score mode as a separate, clearly-labelled path. It's an open design question and I should have raised it in the post.
Dmitri Traces
Storing the feature snapshot on the decision event is the right call. A labelled re-score mode sounds like exactly the kind of thing that should be a separate state rather than a flag on the existing one — otherwise you lose the ability to tell an audit which path a receipt went through.
Anika Realtime
This sub-thread is the most useful thing in the comments section. Model reproducibility on replay is a problem that hits every event-driven ML system, not just printers. Would read a whole post on just this.
Theo Skeptical
I want to gently push back on the framing. The post says jammed printers were "creating silent revenue leaks" and then presents a solution that quarantined 1,284 receipts and auto-recovered 917. That's a 71% auto-recovery rate, which means roughly 367 receipts needed a human, and 301 of those were closed after a paper refill. So a large fraction of the recovered cases were... someone refilling paper. That's not an AI problem, that's a paper problem. I'm not saying the work is wrong. The traceability is clearly valuable. But the narrative leans hard on "AI" when a lot of the wins come from better instrumentation and a discord button.
Baroness YAML von Trace Author
You're not wrong, and I'd rather own that than overclaim. The AI scorer's real contribution is the confidence banding — routing the obvious cases away from humans entirely and surfacing the genuinely ambiguous ones. The 917 auto-recovered receipts would otherwise have all landed in a support queue. But yes, a meaningful share of the remaining cases were hardware, and the discord button exists precisely because "refill the paper" doesn't need a model. If the post reads as AI-washed, that's on my framing. The honest summary is: instrumentation first, triage second, model third.
Rafael Billing
The SAP idempotency-by-receipt-ID bit deserves its own post. We've been burned by exactly this: retries that create duplicate billing documents because the idempotency key was generated at the adapter instead of the source. Generating the receipt ID at the edge and carrying it all the way through is the correct pattern and I wish more posts said so explicitly. One question: how do you handle the case where SAP accepts the document but the completion span never makes it back to the decision topic? Do you reconcile from the SAP side, or does the receipt get stuck in a "maybe billed" state?
Baroness YAML von Trace Author
We reconcile from the SAP side on a scheduled sweep. If a receipt ID exists in our event store but has no corresponding SAP document number after the expected window, a reconciliation job queries SAP by receipt ID and backfills the decision topic. It's a boring batch job that exists purely to close the gap you're describing, and it's saved us more than once during broker partitions. The key detail is that the receipt ID is generated at the printer_sentinel node before the first MQTT publish, so the entire pipeline — bridge, scorer, adapter, reconciliation sweep — shares one immutable key. That's what makes idempotency actually work end to end.
Rafael Billing
The immutable key generated before first publish is the whole ballgame. Everything downstream can be retried, replayed, or reconciled as long as that key never changes. Thanks for spelling it out.
Hexagonal Helen
Structurally, this post describes five or six distinct concerns: printer hardware state, MQTT transport, AI scoring, SAP billing, discord triage, and icinga2 monitoring. You've coupled them through a single rclcpp node and a shared event stream, which works, but I'd argue the printer_sentinel node is doing too much. It wraps the vendor SDK, owns the state machine, publishes MQTT, and annotates Jaeger spans. That's four responsibilities in one component. Did you consider splitting the state machine from the transport layer? I'd worry that a vendor SDK bug takes down your trace annotation along with it.
Baroness YAML von Trace Author
We did consider it, and honestly the rclcpp component is the most tightly coupled part of the design. The vendor SDK is not thread-safe and it's callback-driven, so wrapping it in a single node with a serialized event loop was the pragmatic choice for phase one. Splitting transport out would have meant another process and another failure mode on constrained vehicle hardware. That said, the Jaeger annotation is deliberately kept at the State machine transition layer rather than inside the SDK wrapper, so a vendor crash produces a Faulted transition with a trace span before the node restarts. It's not a clean separation, but it's a survivable one. Phase two's unified rclcpp graph is partly about untangling exactly this.
Priya NewReader
I'll admit I'm not from the self-driving world, so I have a basic question. Why is the receipt confidence score thresholded at 0.93 for billable and 0.80 for human review? Those numbers feel very precise for something described as a "small gradient-boosted model." Is there a story behind how you picked them, or were they tuned against a labelled set?
Baroness YAML von Trace Author
They were tuned against a labelled set of about 40,000 historical print jobs, where "labelled" means we manually verified whether the receipt physically existed after the fact. The 0.93 cutoff was chosen to keep false-billable below a threshold our finance team could tolerate, and 0.80 was chosen to keep the human review queue tractable. They're not magic numbers, but they're also not permanent. The bands are config values and we revisit them whenever the model is retrained. If you take one thing from the post, I'd rather it be the idea of explicit confidence bands with owned thresholds than the specific numbers.
Gregor OpsNight
Two weeks, 1,284 quarantined receipts, 917 auto-recovered. Those are great numbers but they're also small. What does this look like at ten times the fleet size? The discord bot opening threads and the icinga2 aggregation both look like they'd hit human and alerting limits well before the MQTT broker does.
Baroness YAML von Trace Author
You're right that the human layer scales worst, which is why the AI scorer's routing is the real scaling lever. At ten times the fleet, the expectation is that the confidence bands move further apart and the human review queue grows sub-linearly, because most new cases fall into the confident bands. If that assumption breaks, the discord bot becomes the bottleneck and we'd need tiered triage by region. We haven't had to build that yet, but I'd rather say it's unproven than claim it's solved.
Sasha Discord
Small UX observation from someone who lives in discord all day: the refill/replay/escalate buttons are a nice touch, but please tell me the replay button requires a confirmation. An accidental replay of a quarantined receipt into a billing pipeline is the kind of thing that ruins someone's afternoon.
Baroness YAML von Trace Author
It absolutely requires a confirmation, and the confirmation includes the receipt ID, the original trace link, and the billing period it will land in. We added the period display after someone nearly replayed a Q3 receipt in early Q4. Lesson learned the mildly expensive way.
Ferdinand Legacy
The mermaid diagram is a nice touch and I appreciate that the states map cleanly onto something an operator can reason about. DiscordAlert to SAPReconcile via ticket_ack is a slightly odd transition though — does a discord acknowledgement really move a receipt into SAP reconciliation, or is there an intermediate validation step? It reads like the human click is treated as sufficient confidence.
Baroness YAML von Trace Author
The diagram simplifies a real intermediate step. ticket_ack moves the receipt into a re-evaluation queue, not straight to SAPReconcile. The operator's acknowledgement indicates the physical issue is resolved, and then the scorer re-runs against the stored feature snapshot. If it clears the billable threshold, it proceeds; otherwise it stays quarantined. The diagram collapses that for readability, which I now realize makes it look like a human click is sufficient confidence on its own. I'll update it to show the re-evaluation state explicitly — thanks for flagging it.
Lena QuietReader
Long-time reader, first comment. I don't work in autonomous vehicles, but the core lesson here — that a side effect can silently fail while every layer reports success — is one of those things that's obvious in retrospect and invisible in the moment. The three-identical-faults-in-90-seconds quarantine rule is going straight into my own IoT project. Thanks for writing up the failure honestly instead of just the success story.
Baroness YAML von Trace Author
Thank you, genuinely. The silent-success failure mode is the reason we wrote this up at all. The most expensive incidents we've had weren't crashes, they were systems confidently reporting that everything was fine. Glad the quarantine rule is useful to you.