by Intelliverse X

First install at the factory — ZHZN cabinets

Android head units: use ZHZN Android installation and handover. The installation, networking, credential-file and OTA commands below describe the Python/Linux agent. The ZHZN Android APK implements CSM directly and does not require Python, systemd or the Reyeah vending APK.

For the manufacturer's QA bench. This is the sequence for a ZHZN/CSM cabinet coming off the line: what software goes on it, what identity material it must and must not carry, the ten acceptance tests, and the sign-off that lets it ship. The field half of the story — unboxing at a venue — is Receiving a cabinet.

Everything here is traceable to code. Where a step the runbook logically needs does not exist yet, it is in §9 Not yet automated rather than written as an instruction, because a factory will follow an instruction whether or not the endpoint behind it is real.

Choose the controller before installing. Android ZHZN cabinets use ai.intelliverse.zhzn.kioskx, which implements the CSM AA…BB protocol. The Reyeah APK (com.ruiye.jd) is a different application. The Python agent documented below is an alternative for Linux hosts, not an Android prerequisite.


0. What the bench needs before it starts

Three of these are yours to hold. The fourth is not, and it is the one that stops a bench dead with a refusal that looks like a broken cabinet, so check it before you uncrate a batch rather than in the middle of one.

What Where it comes from Without it
Your plant's manufacturer API key (machines:build) Kiosk-X, out of band. export KIOSKX_FACTORY_KEY=… — see §4.2 Every build, test and sign-off call is 403
The fleet secret (ZHZN_DEVICE_SECRET) Kiosk-X, out of band, to a named person — §8 The cabinet cannot register (401), and the bench script cannot make a new serial exist (§7)
The release-signing public key The publishing repo — §2.2 step 4 The cabinet ships un-upgradable: with no key the agent installs nothing
The gateway switched on in the cloud Deployment config, not yours See below

Check bootstrap authentication before starting a fresh batch. A fleet-secret request with no KIOSKX_ZHZN_DEVICE_SECRET configured on the backend returns

503 ZHZN gateway disabled (no device secret configured)

(app/routes/zhzn.py, _device). A fresh cabinet depending on that bootstrap path cannot register until the platform configures it. A cabinet already holding a valid per-device key can still authenticate: key verification precedes the fleet-secret check. The probe below checks the bootstrap path, not every authentication mode or the cabinet's enrolment status:

# a deliberately wrong secret: 401 means the gateway is ON and answering
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H "secret: definitely-not-the-fleet-secret" -H "Content-Type: application/json" \
  "$KIOSKX_API_BASE/zhzn/register" -d '{"equipmentNo": "GATEWAY-PROBE-0001"}'

401 is the answer you want: the gateway is up and refused a bad credential. 503 means bootstrap is unavailable — call Kiosk-X before provisioning fresh units that need it. (400 Missing equipmentNo and 404 Unknown device also mean the gateway is up; see §9 of the field runbook for the full ladder.) The probe serial is never registered, because the secret is refused before anything is provisioned.


1. Before the cabinet is powered on

1.1 The serial

The serial stamped on the cabinet's plate is the identity for the rest of its life. One contract governs it everywhere: letters, digits and hyphens only, 8 to 32 characters (app/machine_serial.py:39-49). VM-2026-0001 is fine; VM_2026_1 is not, and neither is VM-1.

The operator-claim rule is the authoritative one, and the device gateway now enforces it at first contact rather than being more permissive. That choice is about where each failure lands: a serial the gateway refuses fails on your bench, with the cabinet in front of you and the plate not yet riveted on, while a serial the claim refuses fails weeks later in a venue, in front of somebody with no authority to fix it. So a cabinet whose plate breaks the rule is turned away on its very first POST /zhzn/register (app/routes/zhzn.py, _require_claimable) with a message naming every fault on the plate at once:

Serial 'VM_1' contains '_' — only letters, digits and hyphens may appear in a
serial an operator can claim; it is 4 characters — the shortest claimable serial
is 8. An operator could never claim it (POST /api/v1/machines/register requires
letters, digits and hyphens only, 8 to 32 characters), so the cabinet would
reach a venue and be impossible to onboard. Re-stamp the plate before this unit
ships.

Cabinets already registered under the old, looser gateway rule keep working — a validation change must not brick a machine that is selling product — so they are reported instead of repaired, and a fleet finds out from the platform rather than from an installer at a venue:

curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
  "$KIOSKX_API_BASE/api/v1/machines/serials/unclaimable" | python3 -m json.tool

Renaming a live cabinet's serial is not offered, because it would detach the machine from its own sales history, its build record and the plate on its door (app/machine_serial.py:96-108).

1.2 What the unit must ship with

For a fresh bootstrap installation, set these three values in /etc/zhzn-agent.env. The annotated reference configuration (text template) is also in the approved source checkout at docs/reference/zhzn-agent.env.example; your technician must fill it for this cabinet:

  1. ZHZN_CLOUD=kioskx — the agent's default is direct (agent/config.py:31), which points it at the Intelliverse services with a Cognito session instead of the device gateway. Always set this explicitly.
  2. ZHZN_DEVICE_SECRET=<the fleet secret> — the shared bootstrap credential (app/routes/zhzn.py, _device). It is no longer the cabinet's identity: it supports registration and an authorized enrolment; it cannot authenticate gateway calls for an enrolled cabinet. Enrol the unit at the bench — §1.2.1.
  3. ZHZN_SERIAL_NUMBER=<the plate serial> — with this empty the agent derives a serial from /etc/machine-id, or generates a GEN-… value and persists it (agent/provision.py:67-90). A derived serial does not match the paperwork and cannot be pre-mapped, so always set it.

Plus ZHZN_LOCAL_TOKEN, a fresh random value per cabinet, if the unit has a kiosk UI. The loopback /dispense endpoint moves physical product, so it is never anonymous: with no token configured, localctl refuses every dispense (agent/localctl.py:make_handler.Handler._authorized). Never reuse the fleet secret here.

The Python agent reviewed at 8344f108ac1ae2a68b5ccdc7466bda059b58a3ce also reads ZHZN_DEVICE_CREDENTIAL, ZHZN_DEVICE_CREDENTIAL_FILE (default /etc/zhzn-agent-device.key) and ZHZN_ENROL (default on). Its persisted state_dir/device.key takes precedence over the environment seed and rescue file (agent/device_key.py:resolve). A pre-enrolled unit must receive its own key and matching identity; do not copy another unit's state. See the enrolment checks below before shipping.

Cloud key authentication and local Wi-Fi unlock are separate. A key-only unit can poll the cloud, but local /provision refuses an empty or whitespace-only ZHZN_DEVICE_SECRET with 403. The current offline service code still derives from that secret and the machine code; it is not ZHZN_LOCAL_TOKEN, and no independent local service-PIN environment setting is implemented. Have the platform provision the supported setup credentials before relying on that path.

1.2.1 Enrolling the cabinet: its own credential, not the fleet's

ZHZN_DEVICE_SECRET above is now a bootstrap credential, not the cabinet's identity. It supports the legacy device channel and an authorized enrolment; it cannot replace an enrolled cabinet's key. The design and the reasoning are in Per-cabinet device credentials; what a bench needs is short.

Why it matters at a bench. The fleet secret is the same string on every cabinet, and it is in a plain file on a box standing in a shopping centre. While a cabinet is still on it, anyone who reads one cabinet's disk can address every cabinet on the platform — read a competitor's planogram, and open real orders in another operator's book. Enrolling at the bench means the unit leaves the plant already per-cabinet, and gateway calls no longer rely on the fleet secret. Local Wi-Fi code derivation remains a separate use of that secret (§8).

A cabinet that ships un-enrolled still boots and still sells, but it does not enrol itself unattended. The fleet secret no longer buys a credential for a cabinet the cloud has never heard from; somebody who owns the cabinet has to arm it first (see below). Until it enrols, goods and createOrder on that serial still answer on the shared fleet secret — that residual is declared, not a reason to skip the bench enrolment.

Do it here anyway. An arm stops a stranger enrolling the unit out from under you. Enrolling on the bench means the shared secret never spends a day reaching this cabinet's planogram and order book. See the residual in the design.

The bench call, with the same fleet secret already being stamped into the unit's /etc/zhzn-agent.env:

The window has to be opened first. The fleet secret on its own no longer buys an enrolment for a cabinet the cloud has never heard from — that was the residual in §1.2.3, and it is now closed rather than merely described. Somebody who owns the cabinet has to say this one, now:

DEVICE_ID=$(cat /etc/machine-id)

curl -s -X POST -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
  -H "Content-Type: application/json" \
  "$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/device-credential/arm" \
  -d "{\"reason\": \"ZH-FAT-2026.1 bench run\", \"deviceId\": \"$DEVICE_ID\"}"

The bench is the natural place for this: your key can arm a cabinet nobody owns yet whose build record names your plant, which is exactly the unit on the bench, and passing deviceId pins the arm so that only the box in front of you can spend it. Once a cabinet is in an operator's fleet the arm has to come from them or from a Kiosk-X admin (§4.2).

The window is 30 minutes by default (ttlMinutes, 24 hours maximum), one cabinet, one use — the enrolment below spends it. reason is mandatory, because an arm nobody signed is indistinguishable from the attack it exists to stop. If the bench run is abandoned, POST .../device-credential/disarm closes the window rather than leaving it to lapse.

Then the enrolment itself:

# the same machine-id: the enrolment is bound to the box, and reading it here
# means the cabinet cannot later be locked out by somebody else enrolling its
# serial
curl -s -X POST "$KIOSKX_API_BASE/zhzn/enrol" \
  -H "secret: $ZHZN_DEVICE_SECRET" -H "Content-Type: application/json" \
  -d "{\"equipmentNo\": \"VM-2026-0001\", \"deviceId\": \"$DEVICE_ID\"}" \
  | python3 -m json.tool

A 200 carries {keyId, deviceKey, machineCode}. The deviceKey is shown once and is never stored on the platform — it exists in that response and on the cabinet, and nowhere else. Write it into the unit before you close the window:

# keyId:deviceKey, exactly as one string
sudo sh -c 'echo "ZHZN_DEVICE_CREDENTIAL=dk_9f3c…:pQ7…-_A" >> /etc/zhzn-agent.env'

# and the rescue copy, because a wiped state directory cannot enrol again (409)
sudo sh -c 'printf "dk_9f3c…:pQ7…-_A\n" > /etc/zhzn-agent-device.key'
sudo chmod 600 /etc/zhzn-agent-device.key

Both paths are the agent's own defaults (agent/config.py:66-74). The rescue file is not optional paranoia: the gateway refuses a second enrolment for one identity, deliberately, because from its side an identity asking to be issued twice is what a cloned cabinet looks like. Without the file, a board that loses its state directory needs an admin at the cloud end.

Enrolment does not claim the cabinet into a fleet. It has nothing to do with POST /api/v1/machines/register, and §1.3 still applies unchanged: a crated cabinet has no operator on it.

1.2.2 How to check the cabinet is enrolled

Do not infer it from the absence of an error. Read it:

curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
  "$KIOSKX_API_BASE/api/v1/machines/device-credentials/fleet" \
  | python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; \
      print("still on the fleet secret:", d["onFleetSecret"], \
            "| windows open now:", d["armed"], \
            "| machines the secret still reaches:", d["reachableOnFleetSecret"]); \
      print("enrolment window:", d["enrolmentWindow"]); \
      [print(m["machineNo"], m["state"], m.get("keyId"), \
             m["enrolmentBinding"], "ARMED" if m["enrolmentArmed"] else "") \
       for m in d["machines"]]'

state: "enrolled" with a keyId is the answer you want, and it is what the whole rest of §1.2.1 is for. fleet-secret means this cabinet is still addressable with the shared secret and is the work still outstanding — the view sorts those rows first for that reason (app/routes/machine_identity.py, device_credential_fleet). revoked means somebody stopped this cabinet deliberately and it needs an attributed identity reset before it works again.

The other column is enrolmentBinding, and it has three values rather than two. device-id is a binding worth something: the cloud knows which physical box answers for this serial, and an enrolment has to present the same one. unbound means the cloud has not seen a device id from this cabinet, so an enrolment has nothing to bind to and needs an arm. self-asserted is the one to actually worry about — a device id that a fleet-secret call put there, which looks like protection on a screen and is not, because whoever holds the secret is who asserted it (app/device_credentials.py, _binding_label).

At the bench, read state and keyId and ignore this column. A cabinet reads unbound until its first authenticated gateway call, which includes the moment straight after a correct §1.2.1 run: the arm and the enrolment are both handed a device id, but what binds the record is the cabinet reporting one for itself (app/machine_identity.py, note_contact). So every correctly built unit reads unbound here, and a bench that treats that as "needs arming" will re-arm and re-enrol every cabinet it builds and collect a 409 for each. It turns device-id on the first heartbeat, which is a check for after the cabinet is running rather than one you can make here.

enrolmentBinding earns its place in the field runbook, on a cabinet that has been beating for a while and still has not enrolled. It tells you nothing on a unit that enrolled ninety seconds ago.

enrolmentArmed is the blunt one: non-null means the door is open on that cabinet right now. Before a batch is crated it should be null on every row — the armed count in the header is the same thing for the whole fleet, and a bench that leaves it above zero has left windows open on units going into a truck.

reachableOnFleetSecret is not about your batch and is worth understanding before it alarms somebody. It counts every un-enrolled machine record on the platform that the gateway would still serve on the shared secret, ZHZN or not, whether or not it is listed above. It is normally much larger than onFleetSecret, it is not work the bench can do, and the reason it is published at all is in the design.

1.2.3 What a 409 means, and what to do about it

A 409 from /zhzn/enrol is not a broken cabinet. It says this serial already holds a credential, and there are only two ways that happens:

Which one How you can tell What to do
This unit already enrolled — the bench ran twice, or the agent got there first on its own beat the credential's keyId matches the one in this unit's /etc/zhzn-agent.env or /etc/zhzn-agent-device.key Nothing. The cabinet is enrolled; carry on to the acceptance tests
Somebody else enrolled this serial — the residual bootstrap window below the unit holds no credential at all, or a different keyId Escalate to Kiosk-X. An admin clears the binding with POST /api/v1/machines/{no}/identity/reset, which takes a named signer and a reason, and then this unit enrols normally

What used to cause the second row, and what stops it now. A cabinet that had neither enrolled nor ever reported a device id could be enrolled by anyone holding the fleet secret: there was nothing for the cloud to bind the enrolment to, so the binding check never fired and any claimant was accepted. That locked the real cabinet out and showed up as exactly this 409. It is closed — an enrolment now needs either a device id the cloud already trusts or an arm (§1.2.1), and the arm names whoever made it.

A 409 on a unit that has never enrolled is therefore much more likely to be an innocent duplicate than a theft. It is still not hardware: nothing about the board, the bus, the reader or the wiring produces it, so do not take the cabinet apart looking for a fault that is not in the building.

428 is the refusal the bench will actually meet, and on a new cabinet it is the first one every time. It means nothing yet proves this claimant is this cabinet — you skipped the arm in §1.2.1, or the 30-minute window lapsed while the unit was on the bench. Arm it again and re-run the enrolment; the message names the endpoint. It is deliberately not a 403, because the agent treats a 403 on enrolment as revocation and stops asking for good, whereas it retries a 428.

Two further refusals from the same endpoint, so a bench does not confuse them with the 409:

1.3 What the unit must never ship with

Never ship a cabinet already bound to an operator. Do not call POST /api/v1/machines/register at the factory. Registration is what claims a serial into a fleet (app/routes/machines.py:238-245), and once a serial belongs to an operator, a different operator claiming it gets 409 Machine number is already registered to another operator (app/routes/machines.py:265). A cabinet crated for customer B but registered to customer A cannot be fixed at the venue. It needs an ownership transfer (app/transfers.py) driven from the platform side, which means the installer stands next to a machine they cannot onboard. The correct state for a crated cabinet is: no operator, no register call, nothing but a build record.

The same rule shows up in the build record's read model: access follows whoever owns the machine in STORE.machines right now (app/machine_build.py, visible_to), so a mis-registered cabinet also shows its factory test history to the wrong customer.

Never ship two cabinets with the same identity. Two units answering to one serial is not a cosmetic problem, because the serial is the routing key for everything:

The gateway now refuses the second cabinet. An agent sends a stable per-installation deviceId (/etc/machine-id is the obvious source) on register and heartbeat; the first one seen is bound to the machine record, and a different device presenting the same serial is refused with 409 before the record is touched at all (app/machine_identity.py:122-158):

Machine VM-2026-0001 is already bound to a different device. … If this is a
replacement control board, an admin must clear the identity first: POST
/api/v1/machines/VM-2026-0001/identity/reset. If it is a second cabinet, its
serial is wrong.

Refusing the newcomer rather than the incumbent is deliberate. The incumbent is the cabinet currently taking money against this record and attributing it correctly; the newcomer is the ambiguous one, and it is the one a human is standing next to — at your bench or on an installation — so the refusal lands where somebody can act on it. The conflict is also written onto the machine record, not just returned to the device, because a device-side error goes into an agent log nobody tails. The QA lead's view of every serial two cabinets are claiming:

curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
  "$KIOSKX_API_BASE/api/v1/machines/identity/conflicts" | python3 -m json.tool

One caveat, stated rather than hidden: an agent build old enough not to send a deviceId cannot be told apart from its twin by any means available to the cloud, so no conflict is claimed for those cabinets. The identity read says duplicateDetection: "unavailable" for them and publishes the number of times a device has bootstrapped the record beside it — one is normal, five means the cabinet keeps losing its state or is not one cabinet.

Never put the fleet secret anywhere a customer can read it. See §8 Handling the fleet secret.


2. What software actually goes on the head unit

2.1 The honest answer about "the APK"

There are two different artifacts and only one of them is an APK:

Reyeah cabinets ZHZN cabinets
On-machine software Android APK com.ruiye.jd Python agent, headless, plus an optional browser storefront
Drives the dispenser? Yes, the APK contains the FF0055… drop frames No APK can. The agent drives AA…BB over /dev/ttyS4
Distribution S3 artifact, adb install first time, OTA after Copy the tree onto the box; signed OTA tarball after
Version pinning rollout record: version + sha256 release registry + rollout, or a per-cabinet pin (§2.5)

So for a ZHZN cabinet there is no APK sideload step that makes the machine work. If the cabinet has a touchscreen you may also install the kiosk build of the web storefront in a browser, but that is a UI, not the dispenser driver.

There is no pre-flashed factory image in any of these repos. Nothing builds a flashable head-unit image, so the install below is what the factory does by hand (or bakes into its own golden image, which is the factory's own artifact and not versioned by this platform — see §9).

2.2 Installing the agent for the first time

From docs/vending-manufacturers-manual.md §8.2 and agent/config.py:

# 1. Put the tree at the install root the OTA swap expects (agent/config.py:78)
sudo mkdir -p /opt
sudo cp -r zhzn-vending-kiosk /opt/zhzn-vending-kiosk
cd /opt/zhzn-vending-kiosk/agent
sudo pip3 install -r requirements.txt

# 2. State and health directories (agent/config.py:64,138,140)
sudo mkdir -p /var/lib/zhzn-agent /run/zhzn-agent

# 3. The env file — copy the reference and fill the three required values
sudo cp docs/reference/zhzn-agent.env.example /etc/zhzn-agent.env
sudo chown root:root /etc/zhzn-agent.env
sudo chmod 600 /etc/zhzn-agent.env
sudo "$EDITOR" /etc/zhzn-agent.env

# 4. The release-signing PUBLIC key, and the boot guard outside the install
#    root. Skip either and this cabinet can never be upgraded remotely: with no
#    key the agent installs nothing, and with no guard a version that fails to
#    start has nothing to put the working one back. Both are cheap here and a
#    truck roll later.
#    (the fleet key; deploy/zhzn-agent-release.pub.example says where it comes
#    from and how to check its fingerprint)
sudo install -m 0644 zhzn-agent-release.pub /etc/zhzn-agent-release.pub
sudo mkdir -p /opt/zhzn-agent-guard
sudo install -m 0755 /opt/zhzn-vending-kiosk/agent/ota_guard.py \
     /opt/zhzn-agent-guard/ota_guard.py

# 5. Run it under systemd with Restart=always — the OTA swap works by exiting
#    and being relaunched on the new tree, and ExecStartPre runs the guard
#    before every start
sudo cp zhzn-vending-kiosk/deploy/zhzn-agent.service \
     /etc/systemd/system/zhzn-agent.service
sudo systemctl daemon-reload
sudo systemctl enable --now zhzn-agent

The public key is the whole trust anchor for remote upgrades. Its private half lives in the publishing repo's Actions secrets and nowhere else — not in the cluster, not in the artifact bucket — so an attacker who owns either of those still cannot make this cabinet run their code. Verify it is the fleet key before you ship a batch against it:

openssl pkey -pubin -in /etc/zhzn-agent-release.pub -outform DER \
  | sha256sum   # must match the fingerprint on the release key record

Confirm the agent came up and registered itself:

journalctl -u zhzn-agent -n 50 --no-pager
# expect: "zhzn register: serial=<plate serial> -> machine=<machine code>"

That log line is emitted by the cloud (app/routes/zhzn.py, register), so seeing its device-side counterpart plus a machineCode in /var/lib/zhzn-agent/identity.json is the proof the whole chain worked.

If you did not enrol the unit by hand at §1.2.1, it does not enrol itself on first contact. Arm it, then it enrols. A unit that ships with state: "fleet-secret" still boots and still sells; goods and createOrder on that serial still answer on the shared secret until it enrols. Confirm the fleet view in §1.2.2 before the cabinet is crated.

2.3 If the cabinet is a Reyeah unit instead

The Kiosk X vending APK is real, published and versioned, so for completeness:

bash adb uninstall com.ruiye.jd adb install -r reyeah-vending-kioskx-1.2.7-aaa3d485cbaa.apk

2.4 What version the cabinet reports back

A ZHZN cabinet reports its own software version to the machine record, on registration and on every heartbeat, so a field update shows up without the cabinet re-registering:

POST /zhzn/register
{"equipmentNo": "VM-2026-0001", "agentVersion": "1.3.1", "vmcVersion": "v23_s26",
 "deviceId": "6f1c…"}
Field on the machine record Written by Notes
software.agentVersion /zhzn/register, /zhzn/heartbeat The Python agent's version (app/store.py, Store.report_agent_version)
software.platform the same calls zhzn once a cabinet has ever reported; reyeah otherwise
software.vmcVersion the same calls, or /apk/validVmcVersion on Reyeah Controller firmware as it is running now
software.apkVersion /apk/apkUp only Left null on ZHZN cabinets on purpose

The agent version deliberately does not land in apkVersion. A ZHZN cabinet has no APK on it at all, and writing one there would have the console report a version of software that is not on the box — which is a worse failure than the blank it replaced, because it looks like an answer. The console reads software.platform and labels the row "Agent software" for a ZHZN cabinet and "On-machine app" for a Reyeah one, and the shipped-versus-live comparison now fires on both halves: controller firmware and on-machine software.

Set ZHZN_SERIAL_NUMBER and let the agent report; there is nothing for the bench to do here beyond recording shippedFirmware in §5 so there is something to compare against.

2.5 Agent upgrades after the factory

The version you install above is a starting point, not a commitment. CI builds a signed tarball per release and the cabinet upgrades itself, so a fix does not cost a drive to a venue. What the factory owes the fleet is only the two things in step 4 — the public key and the boot guard. Everything after that is remote.

This section is what a bench operator needs to know about upgrades: that the two files in step 4 are what make them possible at all, and what the cabinet does with an offer. Whoever publishes and aims a release wants Upgrading the agent on ZHZN cabinets, remotely instead — key generation and rotation, building and publishing the artifact, staging a rollout, and how to abort one.

The artifact. zhzn-agent-<version>.tar.gz under https://media.intelli-verse-x.ai/docs/agent/, built deterministically so the digest can be reproduced from the tag, plus a .release.json carrying a detached signature over {version, sha256, size, url, migration, minFrom}.

Publishing is not deploying. A published release is offered to nobody until a rollout names it. That separation is the point: the build that lands on main is never, by itself, the build the fleet installs.

A release whose floor nothing can climb to is refused at publish time. minFrom is the lowest version a release may be installed over, and it is a floor the release needs but cannot create for itself: some other release has to put the cabinet on a rung at or above it first. If no such rung exists, both answers available at offer time are bad — hand the cabinet an install that is guaranteed to fail in the venue, or hand it nothing and leave it stuck with no signal — so the decision was moved to the one moment with a person in it and the whole release set in view. Two shapes come back 400:

What that deliberately still permits, so the check does not become a publishing outage: no floor at all; a floor at or below the oldest published release, which is the ordinary case because a floor usually names a build already in the field rather than a rung the ladder needs to insert; and publishing out of ascending order1.5.0 with minFrom: 1.4.0 goes through before 1.4.0 exists, because until something is published below the floor "not yet reachable" is a different claim from "unreachable". CI re-arming the registry after a redeploy publishes into an empty registry and is never caught by this.

The rule guards the publish path and nothing else: it is not an invariant over the registry's lifetime. A release that was reachable when it was published becomes unreachable if the rung beneath it is later removed from the registry — the peer-sync delete path and a sandbox reset can both do that. That residue is why the cabinet checks the floor as well; see A cabinet below a release's floor below.

# what exists, and what the fleet is running
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/releases"
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/fleet"

# prove it on three cabinets first
curl -sX PUT -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"targetVersion":"1.4.0","phase":"rolling",
       "canary":["VM-2026-0001","VM-2026-0002","VM-2026-0003"],"percent":0}' \
  "$BASE/api/v1/agent/rollout"

# then widen it
curl -sX PUT -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"targetVersion":"1.4.0","phase":"rolling","percent":25}' \
  "$BASE/api/v1/agent/rollout"

# the brake — cabinets already on the target keep it, nobody else is offered it
curl -sX POST -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"reason":"canary 0002 is not reporting"}' \
  "$BASE/api/v1/agent/rollout/hold"

Pinning, which this runbook already described for the APK, now means the same thing for the agent. A pin exempts one cabinet from the rollout in both directions: it is neither dragged forward nor left behind.

# hold a cabinet where it is, or walk it back down deliberately
curl -sX POST -H "X-API-Key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"version":"1.3.0","reason":"1.4.0 stalls lane 7 on rev-B boards"}' \
  "$BASE/api/v1/machines/VM-2026-0001/agent/pin"

# and what this one cabinet will be offered next time it polls
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/machines/VM-2026-0001/agent"

A pin below the running version is the deliberate rollback. The automatic revert below catches a build that will not start; only a person can catch one that starts fine and behaves badly.

What the cabinet does with an offer, in the order that matters:

Step Refusal here means Reference
Verify the signature against /etc/zhzn-agent-release.pub nothing is downloaded at all agent/ota.py check_offer
Check the URL is under the trusted prefix a tampered offer cannot aim the fleet elsewhere same
Check the signed minFrom against AGENT_VERSION a cloud bug that offers a release this cabinet cannot install costs a log line, not a truck roll same
Wait for a quiet window no restart between taking money and dropping product agent/agent.py quiescent
Check the digest of the downloaded bytes a truncated or swapped artifact never reaches an extract agent/ota.py apply_update
Extract beside, swap with two renames a failure before the swap leaves the working tree untouched same
Reach the cloud, or be reverted a version that cannot come up puts the old one back by itself agent/ota_guard.py

The last row is the one worth internalising at a factory bench: a cabinet is never left running a version it could not prove. After three failed starts, or after five minutes of running without reaching the cloud, the previous tree comes back and the machine keeps selling. Exactly one previous tree is kept — these are 8-16 GB boards and a filled disk is its own outage.

A cabinet that never goes quiet. Row three is a real reason a rollout stalls: a cabinet only restarts for an upgrade with the vend lock free, nothing leased in the dispatch queue, an empty report spool and three minutes since the last dispense. A busy machine can decline every offer indefinitely, and the only other symptom is a version that never moves — the same thing a cabinet outside the cohort shows. So the cabinet reports what it is declining, for how long and why, on its heartbeat, and the fleet view carries it per machine plus a deferring count:

curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/fleet" \
  | jq '.data | {deferring, machines: [.machines[] | select(.deferring)]}'

There is deliberately no way to make a cabinet restart anyway. A restart between taking money and dropping product either keeps the money or loses the sale, so the remedy for a permanently busy cabinet is a quieter window or a visit — not an override. A row that has been deferring for days is the signal to choose one.

A cabinet below a release's floor. Row three above is the cabinet's own copy of the minFrom check: it holds its AGENT_VERSION and the floor inside the verified manifest, so it compares them itself rather than assuming the cloud did. The floor is read after the signature verifies and never before — minFrom is inside the signed fields precisely so it is the publisher's assertion, and a floor read from an unverified offer would turn this safeguard into a way for anyone who can rewrite an offer to talk a cabinet out of every upgrade it is ever sent. Comparison is semantic, so 1.10.0 is above 1.9.0, and a cabinet standing exactly on the floor installs normally — a refusal is only correct when the floor is genuinely above the running version.

A refusal is not an error and not a crash: the cabinet goes on vending on the version it has and asks again at the next interval. It reports the refusal on its heartbeat in otaBlocked, which is a separate field from otaDeferred and a separate column in the fleet view, because the two need opposite responses. A deferral ends by itself the first night the venue is quiet, and waiting is a real remedy. A cabinet below a floor refuses the same offer at 3am exactly as it does at 3pm, forever, and the only thing that changes it is on this side — publish the rung it can climb to, or aim the rollout somewhere within its reach.

# the two stalls, told apart: `blocked` never fixes itself, `deferring` does
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/agent/fleet" \
  | jq '.data | {blocked, deferring,
                 stuck: [.machines[] | select(.blocked)
                         | {machineNo, agentVersion,
                            needs: .blocked.minFrom, offered: .blocked.version,
                            refusals: .blocked.count,
                            since: .blocked.firstReportedAt}]}'

# and the per-machine read, which shows the live offer beside the refusal
curl -sH "X-API-Key: $ADMIN_KEY" "$BASE/api/v1/machines/VM-2026-0001/agent" \
  | jq '.data | {agentVersion, offer: .offer.version, blocked, deferring}'

A row in blocked reads: this cabinet is on agentVersion, it is being offered blocked.version, and that release will not install below blocked.minFrom. The fix is a publish or a rollout change, never a visit.

Which duration to trust. count and seconds are measured in the agent process and restart with it, so a small number means "since this agent last started", not "since this began". firstReportedAt — on both blocked and deferring — is held by the cloud from the first beat carrying that block and kept for as long as the version, and the floor, do not change. That is the restart-proof answer to "how long has this cabinet been stuck", and it costs the cabinet nothing: persisting the counter on the machine instead would have put a durable write on an eMMC board on a path that currently touches nothing, to recover a number the cloud can hold for free.


3. VMC / MDB pairing

Do this before the acceptance tests, because six of the ten depend on it.

  1. Dispenser bus. RS232 GND / TXD / RXD from the head unit to the CSM dispenser COM. 9600 8N1 (agent/config.py:220-221). The vendor demo uses /dev/ttyS4; the physical COM ↔ /dev/ttySN mapping is board-specific and is one of the items the manufacturers manual marks confirm with hardware.
  2. Board type. ZHZN_MODULE=SH_YAxis for a Y-lift gantry (frames carry an XOR check byte) or SH for a spring control board (no check byte) — agent/config.py:222, agent/config.py:241-243. The wrong value presents as no_ack, indistinguishable from a wiring fault, so set it deliberately.
  3. Handshake. ZHZN_INIT_ON_START=1 (the default, agent/config.py:224) makes the agent send one board Init (0x02) at startup before any dispense. A successful Init is what the vmc acceptance test observes; the lifecycle test asserts exactly this (tests/test_zhzn_lifecycle.py:202-203).
  4. Grid geometry. ZHZN_COLUMNS_PER_ROW must match the physical grid. aisle = (row - 1) * columns_per_row + column (agent/config.py:246-254), and the cloud derives the same mapping when it serves the planogram (app/routes/zhzn.py, _aisle_to_slot). Get this wrong and every purchase lands on the wrong lane. The cloud's default is 10, which tests/test_zhzn_lifecycle.py:201 asserts a factory-fresh machine receives.
  5. Payment bus. Reserve a second COM for the MDB-RS232 adapter that carries the Nayax reader — separate from the dispenser COM. Adapter serial config is 9600 8N1, HEX (docs/vending-manufacturers-manual.md §7). On a ZHZN cabinet the adapter is the MDB master, not the VMC.
  6. Y-lift calibration. Run step calibration (0x03 / 0x07, saved with the 0xFF lane) so aisles land where they should. Per-machine calibration values are hardware, not code.

4. The ten acceptance tests

The set is closed on purpose: a factory that omits the dispense test cannot quietly sign off a cabinet that never vended (app/machine_build.py, TEST_CODES and REQUIRED_TESTS). Read the live list from the API rather than trusting a printout:

export KIOSKX_API_BASE=https://api.kiosk-x.ai
export KIOSKX_FACTORY_KEY=<your plant's manufacturer API key>  # never commit this

curl -s -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
  "$KIOSKX_API_BASE/api/v1/machines/build/tests" | python3 -m json.tool

Every call in §4–§6 uses your plant's manufacturer credential, not a full-admin key. See §4.2 The credential your plant holds.

Code Bench Required? What a pass means on a ZHZN cabinet
power Power-on self test yes Cabinet boots to the storefront unaided after a cold mains cycle. Set the VDC-SEL jumper for auto-power-on after outages, or a venue power cut leaves it dark.
vmc VMC board handshake yes The board Init (0x02) is acknowledged on the dispenser COM.
mdb MDB bus enumeration yes The MDB-RS232 adapter enumerates the peripherals actually fitted.
reader Card reader / cashless yes The reader shows an amount when a sale starts. Cellular "Online" plus a green LED proves the modem only — a reader can be online and completely absent from MDB.
dispense Lane dispense, every lane yes Every lane, not a sample. Each lane's aisle number matches (row-1)*columns+column.
coin Coin mechanism no not_fitted on a cabinet with no coin mech.
bill Bill validator no not_fitted on a cabinet with no validator.
cooling Refrigeration pulldown no not_fitted on an ambient cabinet.
network Connectivity (Wi-Fi / LTE) yes The cabinet reached the cloud, not merely associated. A registration in the log is the proof.
screen Touchscreen and display yes Touch works across the panel. A ZHZN cabinet's screen is load-bearing for field Wi-Fi setup.

Cash handling and refrigeration are optional on every configuration, because a card-only ambient cabinet legitimately has no coin mech to test (app/machine_build.py, REQUIRED_TESTS).

Four result words, and the difference between two of them matters.

Result Means Effect on the sign-off
pass The bench ran and the subsystem worked Satisfies the requirement
fail The bench ran and the subsystem did not work Blocks the sign-off, by name
skip The subsystem is fitted and was not tested — rig down, container waiting Still blocks the sign-off if the subsystem is required
not_fitted The cabinet was built without this subsystem Drops it from the required set for this cabinet

A subsystem that does not exist has not been skipped, and must not read as tested. Keeping the two words apart is what stopped a cash-only cabinet from being signable only by recording a reader result that never happened — the sign-off has a named signer on it, and a record that forces one false entry has no claim on being believed about the others.

not_fitted is accepted only for mdb, reader, coin, bill and cooling (app/machine_build.py, FITTABLE). Those are the line items a real order actually specifies away. Anything else — power, vmc, dispense, network, screen — is refused with 400, because a machine with no dispenser is not a vending machine and a claim of absence there is a bench trying to skip a bench.

The required set as it applies to the cabinet in front of you is stated on the record, so you never have to derive it: testSummary.requiredForThisMachine, alongside notFitted and skipped as separate lists (app/machine_build.py, required_tests and test_summary).

4.1 Recording each result

One call carries as many results as you have:

curl -s -X POST -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
  -H "Content-Type: application/json" \
  "$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build/tests" \
  -d '{
    "suite": "ZH-FAT-2026.1",
    "results": [
      {"code": "power",    "result": "pass", "testedBy": "L. Zhou"},
      {"code": "vmc",      "result": "pass", "testedBy": "L. Zhou"},
      {"code": "mdb",      "result": "pass", "testedBy": "L. Zhou"},
      {"code": "reader",   "result": "pass", "testedBy": "L. Zhou"},
      {"code": "dispense", "result": "pass", "testedBy": "L. Zhou",
       "detail": "60 of 60 lanes"},
      {"code": "network",  "result": "pass", "testedBy": "L. Zhou"},
      {"code": "screen",   "result": "pass", "testedBy": "L. Zhou"},
      {"code": "coin",     "result": "not_fitted", "detail": "No coin mech fitted"},
      {"code": "bill",     "result": "not_fitted", "detail": "No validator fitted"},
      {"code": "cooling",  "result": "pass", "testedBy": "L. Zhou",
       "detail": "4C in 41 min"}
    ]
  }'

Rules worth knowing before you use it:

4.2 The credential your plant holds

Your plant is issued a manufacturer API key, not a platform admin key. The distinction is not bureaucratic: a full-admin key reads and writes every operator's data on the platform, and that is not an acceptable thing to email to a third-party factory.

The key carries exactly one scope, machines:build, and is pinned to your manufacturer name (app/config.py:776-787, app/auth.py:54-79).

What it can do, for serials whose build record names your plant:

What it cannot do, each of which is covered by an explicit test in tests/test_factory_credential.py:

Set it as KIOSKX_FACTORY_KEY; the bench script reads that variable first and falls back to KIOSKX_ADMIN_KEY only for platform-side use.


5. The build record

What was built, by whom, on what. Keyed by serial so it survives the purchase order being archived (app/machine_build.py, _blank).

The cabinet has to exist as a machine record first. The build record hangs off one, so PUT …/build on a serial the platform has never seen is a 404, not a create — and so are …/build/tests and …/build/signoff. Power the unit on and let it make its own POST /zhzn/register (§2.2) before you write the record; that is what auto-provisions the row (app/store.py, Store.auto_provision_machine). On a normal bench this happens by itself, because the agent is installed long before the paperwork is written — it bites a plant that records paperwork first, or reruns against a fresh serial. §7's script makes the register call for you when the serial is unknown.

Do not reach for POST /api/v1/machines/register to make the row appear. That is the operator claim, and §1.3 is why.

curl -s -X PUT -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
  -H "Content-Type: application/json" \
  "$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build" \
  -d '{
    "manufacturer": "zhzn",
    "model": "T3568-6x10",
    "orderRef": "PO-2026-0412",
    "batchRef": "ZH-2026-04",
    "boardRevision": "T3568-rev-C",
    "factory": "Ningbo line 2",
    "builtAt": "2026-04-02",
    "shippedAt": "2026-04-05",
    "shippedFirmware": {"vmcVersion": "v23_s26", "apkVersion": "1.3.0"}
  }'
Field Notes
manufacturer Closed set: hotbox, reyeah, zhzn (app/machine_build.py, MANUFACTURERS). Anything else is refused with the list.
model, orderRef, batchRef The batch is how a bad component is traced across a run. Fill it even when it feels redundant.
boardRevision The head-unit / controller revision. This is what the board manufacturer asks for when a cashless parameter question comes up.
factory, builtAt, shippedAt Free-text factory/line and ISO dates.
shippedFirmware.vmcVersion Controller firmware as shipped. Deliberately distinct from the machine's live software block, and the gap between them is how a field update is proven (app/machine_build.py, upsert_build).
shippedFirmware.apkVersion On-machine software version as shipped. On a ZHZN cabinet, record the agent version (agent/version.py AGENT_VERSION, 1.3.0 at the time of writing). The cabinet now reports its own agent version from the field (see §2.4), so this value is the other half of a comparison that actually fires: the console shows it as "Agent software at ship" against "Agent software now".

Only the keys you send are written, so a later call reporting just a ship date does not blank the board revision (app/routes/machine_build.py, BuildIn).

The same PUT also accepts an acquisition block — cost, funding, supplier, useful life (app/routes/machine_build.py, AcquisitionIn). A factory does not fill it. That is what the operator paid for the cabinet and how it depreciates on their books, and it is on this endpoint only because both live on one record. Sending it from the line would write a purchase price the factory does not know. Because the write is a partial update, omitting it leaves any existing acquisition data untouched.

The record's state is derived, not stored: recordedtestingsigned_offaccepted (app/machine_build.py, _view). It appears in the operator console as the Build & factory QA card (kiosk-x-operator/components/MachineBuildCard.tsx), which is where the operator sees your test results and the failure text you wrote.


6. Factory QA sign-off

curl -s -X POST -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
  -H "Content-Type: application/json" \
  "$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build/signoff" \
  -d '{"by": "L. Zhou", "role": "Line QA", "note": "Crated for freight"}'

It is blocked, deliberately, in three cases. A signature on an incomplete suite is worse than no signature, because it looks like assurance (app/machine_build.py, factory_signoff):

Refusal Status Message
A required test has no result on file 409 Cannot sign off: no result recorded for dispense, screen — it names them, so you know which bench the cabinet goes back to
Any subsystem's latest result is fail 409 Cannot sign off: failing reader
Already signed 409 Already signed off by <name> on <date> — sign-offs are append-only; a second one would rewrite who vouched for the cabinet

Plus 400 if by is blank: a sign-off needs the name of the person signing (app/machine_build.py, factory_signoff).

Check readiness before you try. testSummary.readyForSignoff is the same precondition the guard uses, stated once so the API, the console and the guard cannot disagree (app/machine_build.py, test_summary):

curl -s -H "X-API-Key: $KIOSKX_FACTORY_KEY" \
  "$KIOSKX_API_BASE/api/v1/machines/VM-2026-0001/build" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["testSummary"])'

What the factory does not do is accept the machine. Operator acceptance is a separate signature that only the operator's own credential can make — a full admin attempting it gets 403 (app/routes/machine_build.py, post_accept). Your sign-off is the precondition for theirs (app/machine_build.py, operator_acceptance).

Fleet view of everything still open. This one is platform-side: it spans operators, so it needs machines:read and a manufacturer key does not carry it (app/routes/machine_build.py, build_summary). Ask Kiosk-X for it, or read your own cabinets one at a time.

curl -s -H "X-API-Key: $KIOSKX_ADMIN_KEY" \
  "$KIOSKX_API_BASE/api/v1/machines/build/summary" \
  | python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; \
      print("by state:", d["byState"]); print("failing:", d["failing"]); \
      print("awaiting acceptance:", d["awaitingAcceptance"])'

7. The whole bench run as one command

scripts/factory_provision_machine.py does §4–§6 in the order the API demands, and prints the rule that fired if a step is refused. It carries no secrets: the key comes from the environment.

export KIOSKX_API_BASE=https://api.kiosk-x.ai
export KIOSKX_FACTORY_KEY=<your plant’s manufacturer API key>

python3 scripts/factory_provision_machine.py VM-2026-0001 \
    --manufacturer zhzn --model T3568-6x10 \
    --order-ref PO-2026-0412 --batch-ref ZH-2026-04 \
    --board-revision T3568-rev-C --factory "Ningbo line 2" \
    --built-at 2026-04-02 --shipped-at 2026-04-05 \
    --vmc-version v23_s26 --apk-version 1.3.0 \
    --pass-all --not-fitted coin --not-fitted bill --suite ZH-FAT-2026.1 \
    --signed-by "L. Zhou" --role "Line QA" --note "Crated for freight" \
    --signoff

A cabinet that failed a bench, recorded honestly and not signed:

python3 scripts/factory_provision_machine.py VM-2026-0002 \
    --manufacturer zhzn --pass-all \
    --fail dispense="Lane 7 motor stalls" \
    --signed-by "L. Zhou" --signoff
# → NOT READY TO SHIP: failing: dispense — repair and retest
# → exit 1, and the sign-off is never attempted

Then after the repair, the retest and the signature — --pass names only the bench that ran again, and the later result supersedes the failure:

python3 scripts/factory_provision_machine.py VM-2026-0002 \
    --pass dispense --signed-by "L. Zhou" --role "Line QA" --signoff

The script never invents a pass. Without --pass-all only the subsystems you name are reported, and the sign-off then fails the required-test guard, which is the correct outcome. Feed real bench output with --results-file when the rig can produce JSON.

--not-fitted and --skip are different claims and the script keeps them apart: --not-fitted coin says this cabinet has no coin mech, while --skip coin says it has one the bench did not run. Only mdb, reader, coin, bill and cooling may be declared absent, and the script refuses anything else before it writes a single record.

This was verified end to end against a local API on 2026-08-30: a build record written, an incomplete suite correctly refusing the sign-off, a retest superseding a failure, a full-green sign-off accepted, a second signature refused with Already signed off by L. Zhou, and operator acceptance flipping the record to accepted.

7.1 Enrolling from the bench script

--enrol does §1.2.1 as part of the same run: register the serial if the cloud has never seen it, arm the cabinet, then enrol it. The three are one flag because the order matters and getting it wrong produces a 404 or a 428 that looks like a fault.

python3 scripts/factory_provision_machine.py VM-2026-0001 \
    --manufacturer zhzn --enrol --device-id "$(cat /etc/machine-id)" \
    --pass-all --signed-by "L. Zhou" --role "Line QA" --signoff

Pass --device-id when the script runs on the head unit, which is the usual case: the arm is then pinned to that box and cannot be spent by anything else, even inside its window.

The script prints the keyId:deviceKey pair once, and the two lines that write it into the unit and its rescue file. Nothing on the platform can print it again — that is the point of it — so a run whose output is lost means an identity/reset at the cloud end, not a second enrolment.

If the enrolment is refused the script says which rule fired and carries on with the build record rather than aborting the bench. A 428 there means the arm did not take or had already lapsed; re-run with --enrol alone.

A cabinet crated un-enrolled is still shippable, but it no longer enrols itself off the fleet secret when it reaches a venue — somebody has to arm it in the field (field runbook §4.2). That is the cost of --enrol being opt-in, and it is the argument for using it on every unit.


8. Handling the fleet secret

ZHZN_DEVICE_SECRET is the fleet bootstrap secret. Against a cabinet that has enrolled (§1.2.1) it cannot authenticate gateway calls — _device() will not accept it for a machine that holds an active credential — but against a cabinet that has not, it is still the entire device credential, and it is one string that covers every un-enrolled cabinet on the platform. The gateway risks below concern un-enrolled cabinets. Local Wi-Fi codes remain derived from the shared secret even after enrolment when that secret is still provisioned on the unit; enrolment does not rotate those codes.

What someone with the fleet secret can do to an un-enrolled cabinet, using only a serial:

Rotating it rotates every service code and setup-AP passphrase in the fleet, so a rotation invalidates every printed or cached code at once.

Delivering it to a factory. Out of band, to a named person, never in the same channel as the build paperwork or the serial list, and never in a file that gets committed. Prefer a per-factory secret if the deployment supports more than one, so a compromise at one plant does not cover the whole fleet. The reference configuration ships the literal string REPLACE_ME_WITH_THE_FLEET_SECRET precisely so a half-provisioned unit is obvious rather than subtly wrong.

The bootstrap path fails closed. With no KIOSKX_ZHZN_DEVICE_SECRET configured, a fleet-secret request returns 503 ZHZN gateway disabled (no device secret configured) (app/routes/zhzn.py, _device). Valid per-device keys are checked first and do not require that fleet secret. An invalid key is refused without falling back to the shared secret. On the legacy path, a wrong secret is 401, and a valid secret with no equipmentNo is 400 (tests/test_zhzn_lifecycle.py:336-343).

Consider KIOSKX_ZHZN_STRICT_MAP=true (app/config.py:91) for a production fleet. In allowlist mode only serials present in KIOSKX_ZHZN_MACHINE_MAP may register, so a leaked secret can no longer conjure new machines — it can only impersonate ones you already own.


9. What is not yet automated

Real gaps. Each is something this runbook would otherwise want to tell you to do, and cannot.

  1. No pre-flashed image, and no image versioning. Nothing in these repos builds a flashable head-unit image. A factory that bakes a golden image is producing an artifact this platform does not track, so two "identical" batches can differ silently. Note that a cloned golden image with a hard-coded ZHZN_SERIAL_NUMBER is now caught by duplicate-identity detection (§1.3) rather than silently sharing a machine record — but only for agent builds that report a deviceId.
  2. No API adds a serial→machine-code mapping. KIOSKX_ZHZN_MACHINE_MAP is deployment config (app/config.py:68), so a serial that must map to a different machine code needs a config change and redeploy. An unmapped serial uses itself as the machine code, which is why pre-staged codes work without one.
  3. No manufacturer webhook. A manufacturer credential now exists (§4.2), so a factory writes its own records over the normal API. What still does not exist is a push channel: nothing lets a plant's MES post a batch of results without holding an API key and driving the three calls itself.
  4. Identity binding depends on the agent volunteering a deviceId. Older agent builds send none, and for those cabinets the cloud cannot tell an incumbent from a twin, so duplicate detection reports duplicateDetection: "unavailable" rather than a clean bill of health. A per-machine device credential is supported by the reviewed Python agent; enrolment and the fleet checks in §1.2 are required to establish that a particular cabinet is using it. Older installed builds need separate review.

Next: Receiving a cabinet for installation · Vending manufacturers manual · Changing a machine's Wi-Fi · APK rollouts