The Golden Image

A Kirin board is sold to someone who will never open a terminal. That requires a repeatable path from a blank microSD to a unit a stranger could unbox — booting into the touch UI, updatable over the air, and traceable back to a physical object.

That path is three tracks, deliberately separate because they run at different times and by different people: build the master (once per image revision), capture the image (once per revision), and flash and provision a unit (per board, at assembly). A fourth path, the dev bench, deliberately does not use the image at all.

What the card locks in forever

D-85 — The golden image locks three properties that cannot be changed on already-flashed boards

Decided 2026-07-18 · Origin: docs/golden-image-docs/RUNBOOK.md §1 · Status: in force

Context v1 OTA swaps the application, not the OS (D-86). So anything below the application is frozen at the moment the image is cut.

Decision Treat three properties as decisions to be made deliberately, up front, and sign them off: the partition table, the image floor (the software layers OTA can never replace), and the card size.

Rejected Treating the image as “just a Pi we set up”, which is exactly how a fleet ends up needing a hand reflash to gain a capability.

Consequences Each of the three is recorded below with the reasoning, because a future reader needs to know not just what was chosen but what it costs to change.

The partition table

The shipping card is carved for a future it does not yet use:

#

Name

Size

Mount

Purpose

p1

boot

512 M

/boot/firmware

Firmware, kernel, config.txt

p2

rootA

12 G

/

The running OS

p3

rootB

12 G

Reserved, empty. The A/B slot

p4

data

$\sim$<!-- -->{=html}33 G

/data

/opt/kirin $+$ /home

Three points are worth keeping. 12 G for a rootfs that uses about 3 G is roughly four times headroom, chosen because this is a number that can never be changed on a shipped board, so the asymmetry favours generosity. rootB is left unformatted because an A/B update writes a whole filesystem image into the inactive slot rather than upgrading into a mounted one — an empty, unformatted partition is the honest representation of “reserved” and cannot be mistaken for something in use. And /opt/kirin and /home are bind-mounted onto data, because those hold the two things that must survive an OS swap: the OTA release bundles and the board’s identity. On the rootfs, the day you flip to rootB the board would come up with no releases and no idea which unit it is.

The image floor

The OTA bundle vendors Qt, so Qt is updatable. Everything below Qt loads from the image and is frozen until reflash.

Layer

Golden master (2026-07-18)

OTA?

OS

Debian 13 “trixie” (RPi OS 64-bit Lite)

no

glibc

2.41

no

Kernel

6.18.34+rpt-rpi-2712

no

Mesa / EGL / GBM / libdrm

installed by the provisioning script; absent from stock Lite

no (eglfs dlopens the system copies)

Host config

polkit rules, Wi-Fi country, config.txt, systemd units

no

Qt

6.8.2 — but the bundle vendors its own

yes

App, QML, NNUE net

yes

Warning

CI must build the OTA bundle inside a container matching this userspace. Build anywhere newer and the bundle silently will not run on the board — it links against a glibc the image does not have. The bundle build script carries the same statement; keep the two in step when the image is bumped.

The card size

64 GB. The deciding fact is mechanical: capture produces an image the size of the card you captured from, not the size of the data on it. A master built on a 470 GB card yields a 470 GB image that only flashes onto $\geq$<!-- -->{=html}470 GB cards — useless. Build the master on the shipping card.

The $\sim$<!-- -->{=html}33 G of data partition is deliberate headroom for several OTA release generations, recorded games, and — the reason 64 was chosen over 32 — room for additional game titles as separate app bundles later. The partition is sized for that; the OTA layout is not yet (releases/<version> with one current symlink models one application with many versions, not many applications), and that is an open design question for whenever a second title becomes real.

Unit serials

D-95 — Unit serials are four-digit zero-padded decimal, with 00010099 reserved for internal boards

Decided 2026-07-19 · Origin: docs/golden-image-docs/RUNBOOK.md §3 · Status: in force

Context The serial is the hostname (kirin-NNNN) and the provisioning key’s label, and it is what makes a device row trace back to a physical board.

Decision Four rules, each cheap now and expensive to retrofit:

  • Fixed width, zero-padded — unpadded serials sort lexically, so unit 2 lands after unit 19 in every list and dashboard anyone ever builds.

  • Decimal, not hex — the use case is a human reading a sticker and typing it. Hex trades that for density nobody needs and adds B/8, D/0, and a permanent “is 0100 unit 256 or unit 100?” ambiguity.

  • No encoded metadata — batch, hardware revision and build date belong in the device row where they can be corrected. A serial is immutable, so a board reworked to a later revision would lie about it forever.

  • Never reuse a serial, including for scrapped or reflashed boards. A serial names a physical object’s whole history.

Rejected A free-form or metadata-encoding scheme, and reuse of retired serials.

Consequences The reserved 00010099 block exists because bench cards are reflashed constantly and each pass leaves a redeemed key and a device row behind. Without the split, that noise is indistinguishable from shipped units in the fleet view.

Per-unit personalization writes both pieces of identity offline from the card reader — hostname onto the root partition, provisioning key into the settings file on the data partition — with a dry run, a refusal on an already-keyed card, and read-back verification.

The dev bench is not a fleet board

D-92 — The dev bench deliberately does not use the golden image, and the two paths must not merge

Decided 2026-07-18 · Origin: docs/golden-image-docs/RUNBOOK.md §7 · Status: in force

Context Development needs to build on the Pi and restart in seconds. A fleet board must never do either.

Decision Two launch models that coexist because each owns a different path:

Dev bench

Fleet

Where the app lives

a build tree on the Pi

/opt/kirin/current/ (unpacked bundle)

How it updates

the deploy script from a dev machine

signed OTA bundle, owner taps Install

Qt

the image’s apt Qt

vendored inside the bundle

run-gui.sh takes the fleet branch only if /opt/kirin/current/run.sh exists, and nothing on a dev box creates it.

Rejected One unified provisioning path. A single model would either force fleet boards to carry a toolchain or force the bench to go through a release cycle for every change.

Consequences Running the host provisioning script on a bench Pi is safe and does not hijack it. Seeding a bundle does flip it to the fleet path — that is the one command that turns a bench board into a fleet board, and it is worth knowing which one it is.

Note

A related consequence, easy to lose an afternoon to: the tree on a bench Pi has no .git. Nothing propagates to it automatically — not a push, not a merge, not an OTA of an app-level change. It is replaced wholesale by the deploy script, which is the only supported path.