# bollardeck API

Version 0.1-draft · Status: speculative draft · Updated 2026-11-07

> **Speculative draft.** No Pop Boll hardware has shipped, and most of this API has not run against a real Boll yet. Message names, fields and limits will change without notice, and nothing here is an offer, a commitment or a promise of compatibility. We would love to hear about partnership, OEM and dev-kit ideas: use the contact form on this site.

bollardeck is the software side of Bollard: everything above the physical connector, which is specified in the [Bollard Physical Specification](bollard.md). Docks, cartridges and routines all plug into the Pop Boll, "the deck", through it. The [overview page](index.md) explains how the pieces fit together.

## 1. Overview

| Part | For | What it covers |
| :--- | :--- | :--- |
| **A. Docks** (§2–§10) | anyone building a dock or an accessory | what a dock can read from a Pop Boll (now playing, MRPH5 stems, LED frames, buttons, battery, corpus labels) and what it can send (audio routing, volume, transport, remix and stem-balance requests, light sync), over USB or BLE |
| **B. Routines** (§11–§16) | anyone who wants to change how the Boll writes songs | custom Python logic that runs inside the song factory on your own Boll, working only with the corpora already on the device |
| **C. Bolltridges** (§17–§20) | anyone building a cartridge computer | the extra messages for docks that run their own Linux computer: clock, MIDI, voice takeover, stem offers, power and health, and image updates |

| Item | Version |
| :--- | :--- |
| This document | 0.1-draft |
| Wire protocol (Parts A and C) | `bollardeck/1` |
| Message envelope | `"v": 1` |
| Routine API (Part B) | `api: 1` |
| Bolltridge extension (Part C) | 0.1 |

**Status.** The routine hooks of Part B have a working implementation in development builds of the song factory. The Boll-side service for Parts A and C (dock sessions, the BLE layout, the consent prompts and cartridge support) is specified but not built. §22 lists everything that is still open.

Conventions:

- The key words **MUST**, **MUST NOT**, **SHOULD**, **RECOMMENDED** and **MAY** are used as described in RFC 2119.
- JSON examples are sometimes wrapped for reading. On the wire every message is a single line.
- An ellipsis (…) in an example stands for an elided value.
- Latencies and transfer rates marked *estimate* have not been measured.

## 2. Part A: Docks

A **dock** is anything that mates with the Boll's foot, on any Bollard profile, or an accessory with no physical connection that talks over BLE. A dock always talks to the Boll's bollardeck service. The song factory itself never talks to a dock.

| Dock | Transport |
| :--- | :--- |
| USB-C profile | USB CDC-ACM (REQUIRED by Bollard rule C3) |
| Full profile, active-USB module | USB CDC-ACM |
| Full profile, passive module | BLE only |
| Lite or Passive profile | BLE only: there is no data path on the connector |
| No physical connection | BLE |

A session opens after the physical layer has worked out what docked ([Bollard §12](bollard.md#12-enumeration-and-identity)):

```text
 physical attach ──► (Full)  CHAIN and I2C header: vid, pid, class bit 11 "bollardeck"
                 └─► (USB-C) USB descriptors: a CDC interface named "bollardeck"
                 └─► (any)   BLE: the DECK_INFO characteristic
 dock  -> hello      vid and pid MUST equal the physical header's, when there is one
 boll  -> welcome    capabilities granted now, and those pending the user's consent
 boll  -> state, nowplaying, …   only the topics that are granted and subscribed
```

## 3. Transports

### 3.1 USB CDC-ACM

- The dock is the USB device and the Boll stays the USB host (Bollard rule U1).
- The dock exposes a CDC-ACM interface whose **interface string is `bollardeck`**. The Boll binds the interface by that string, and the baud rate is ignored.
- One CDC interface carries one session.
- Latency is about 1–2 ms (estimate).

### 3.2 BLE GATT

- The dock is a peripheral and the Boll is the central.
- Pairing uses LE Secure Connections. The first pairing needs a long press of the Boll's inner (play/pause) button.
- Latency is about 15–30 ms (estimate).
- Airtime is shared with Bluetooth audio and the Boll's other accessories, so the Boll MAY lower `led` messages to 4–10 Hz on BLE.

The UUIDs below are **placeholders**. A random 128-bit base will be allocated, and the final UUIDs registered, with version 1.0.

| Item | UUID (placeholder) | Properties | Content |
| :--- | :--- | :--- | :--- |
| bollardeck service | `b011a2d0-0000-4c6b-9e1a-6f2d0b011a2d` | primary | advertised in the scan response, with the dock's product name |
| `DECK_TX` (Boll → dock) | `b011a2d0-0001-4c6b-9e1a-6f2d0b011a2d` | notify | the JSON-lines byte stream in ATT MTU-sized chunks (≤ 244 bytes with Data Length Extension), reassembled at each newline |
| `DECK_RX` (dock → Boll) | `b011a2d0-0002-4c6b-9e1a-6f2d0b011a2d` | write without response | the same, in the other direction |
| `DECK_INFO` | `b011a2d0-0003-4c6b-9e1a-6f2d0b011a2d` | read | `{"v":1,"api":["bollardeck/1"],"vid":…,"pid":…}`, so the Boll can filter before connecting |
| `DECK_LED` (optional) | `b011a2d0-0004-4c6b-9e1a-6f2d0b011a2d` | notify | binary LED frames (§6.3) for docks that want lights without parsing JSON |

## 4. Envelope and errors

### 4.1 Framing and envelope

- **JSON Lines.** Each message is one JSON object on one line, UTF-8, terminated by a newline (`\n`), at most 64 KiB.
- **Envelope.** Every message has `"v": 1` (the envelope version) and `"t": "<type>"`. All other fields are the payload, at the top level.
- **Requests and replies.** A request MAY carry `"id"`, an integer chosen by the sender. The reply carries `"re"` with that id.
- **Unknown fields MUST be ignored.** An unknown `t` is answered with an `unknown_type` error, and the session stays open.

```json
{"v":1,"t":"volume","id":12,"set":0.55}
{"v":1,"t":"ok","re":12}
```

### 4.2 Rates

- The Boll MAY drop or coalesce messages to keep the link healthy, but it never drops `button`, `remix_status` or `error`.
- A dock SHOULD keep its own requests to at most 20 per second.

### 4.3 Errors

```json
{"v":1,"t":"error","re":7,"code":"not_granted","msg":"stems needs the user's consent"}
```

| Code | Meaning |
| :--- | :--- |
| `unknown_type` | the message type is not known; the session stays open |
| `bad_request` | the message is malformed or a field is out of range |
| `not_granted` | the capability the message needs has not been granted |
| `pending_consent` | the capability is waiting for the user's consent on the Boll |
| `rate_limited` | the dock is sending too fast |
| `no_common_api` | the dock and the Boll share no protocol major version |
| `identity_mismatch` | the `hello` identity does not match the physical header, or (for a cartridge) the chain position, power or audio declaration does not match the descriptor |
| `not_found` | the requested song, bundle or item does not exist |
| `corpus_rules` | the corpus does not allow this (for example, stems of a corpus song) |
| `busy` | the Boll cannot take the request right now |

Part C adds `unsigned_image`, `power_budget`, `chain_depth`, `thermal`, `voice_busy`, `ota_failed` and `tier_too_low` (§19.9).

## 5. Handshake and capabilities

### 5.1 hello

The dock opens every session with `hello`.

```json
{"v":1,"t":"hello","id":1,"api":["bollardeck/1"],
 "dock":{"vendor":"Example Labs","product":"Bass Lean","vid":4617,"pid":49152,
         "profile":"usb-c","fw":"0.1","url":"https://example.org/bass-lean"},
 "want":["state","nowplaying","stems","beat","led","buttons","corpus"],
 "can":["remix","stem_balance","volume","transport","audio_out","audio_in","light_sync"]}
```

| Field | Meaning |
| :--- | :--- |
| `api` | every protocol major the dock speaks; the Boll picks the highest one in common |
| `dock.vendor`, `dock.product` | names shown in the Boll's UI |
| `dock.vid`, `dock.pid` | USB-style vendor and product IDs. A physically attached dock MUST send the same values as its Bollard header |
| `dock.profile` | the Bollard profile: `full`, `lite`, `usb-c` or `passive` |
| `dock.fw`, `dock.url` | firmware version and a product page |
| `want` | the topics the dock wants to read (capability flags of §5.4) |
| `can` | the things the dock wants to send or control |

### 5.2 welcome

```json
{"v":1,"t":"welcome","re":1,"api":"bollardeck/1",
 "boll":{"model":"Pop Boll Rev B","fw":"…","serial":null},
 "grants":["state","nowplaying","beat","led","buttons","corpus","volume","transport",
           "light_sync"],
 "pending":["stems","remix","stem_balance","audio_in"],
 "limits":{"led_hz":60,"max_line":65536}}
```

- `grants` are the capabilities the dock may use now.
- `pending` capabilities wait for the user's consent (§5.3).
- `boll.serial` is `null` unless the user has turned on "share serial with docks".
- `limits` gives the LED frame rate the Boll will send and the maximum line length.
- Granted topics named in `want` are sent from the start (the example dock in §10 relies on this). `subscribe` and `unsubscribe` change the set later (§7.1).
- A physical dock whose `hello` vid and pid do not match its header gets an `identity_mismatch` error, and the Boll then grants only `state` and `nowplaying`.

### 5.3 Consent

The first time a given dock asks for a consent capability, the user approves it on the Boll with a long press on the medallion, or in the Boll's local web UI. A dock is identified by its vid, pid and its USB serial number or BLE identity. When the grants change, the Boll sends:

```json
{"v":1,"t":"grants","grants":["state","nowplaying","beat","led","buttons","corpus","volume",
 "transport","light_sync","stems","stem_balance"]}
```

### 5.4 Capability flags

| Flag | Direction | Default | Meaning |
| :--- | :--- | :--- | :--- |
| `state` | read | granted | battery, charging, volume, mode, online |
| `nowplaying` | read | granted | song metadata and position |
| `corpus` | read | granted | labels and ids of the active corpora (never their files) |
| `beat` | read | granted | beat and bar events |
| `led` | read | granted | persona colours |
| `led_ring` | read | granted | the full 24-LED light-ring frame |
| `matrix` | read | granted | optional: the 13 × 8 LED matrix frame and its RGB LEDs |
| `buttons` | read | granted | medallion button events |
| `stems` | read | **consent** | MRPH5 bundles of the current song (§8) |
| `volume`, `transport` | write | granted | volume and play, pause, stop, next, previous |
| `light_sync` | write | granted | the dock drives or offsets the Boll's lights (§7.7) |
| `remix`, `stem_balance` | write | **consent** | remix and re-balance requests (§7.4, §7.5) |
| `audio_out` | write | granted | the Boll's mix to the dock over USB Audio (§7.6); not over BLE |
| `audio_in` | write | **consent** | the dock's audio into the Boll over USB Audio (§7.6); not over BLE |

## 6. Boll to dock messages

### 6.1 Song and device state

| `t` | Rate | Grant | Content |
| :--- | :--- | :--- | :--- |
| `state` | on change | `state` | battery, charging and power source, volume (0–1), mode, whether the Boll is online, and what the factory is doing: `idle`, `planning`, `jamming`, `overdub`, `exporting` or `paused` |
| `nowplaying` | on change, and every 5 s | `nowplaying` | song id, title, origin, form, corpora, tempo, key, time signature, the part list, the routines that shaped the song, and the playback position |
| `corpus` | on change | `corpus` | the active corpora: id, label, number of songs, whether it is a remote corpus, and the edition |

```json
{"v":1,"t":"state","battery_pct":72,"charging":true,"source":"dock_vbus","volume":0.6,
 "mode":"playing","online":false,"factory":"jamming"}

{"v":1,"t":"nowplaying","song_id":"pb0042_d03_s012_…","title":"…","origin":"factory",
 "form":"single","corpus_ids":["0007-DEMO"],"corpus_labels":["PB 0007 — Demo corpus"],
 "bpm":118.0,"key":"A Major (Hexatonic Pop)","time_sig":"4/4",
 "parts":[{"index":1,"label":"Intro","category":"Intro","bar":1,"bars":8}],
 "routines":["mixolydian_bridge"],"position_ms":12345,"duration_ms":201000}

{"v":1,"t":"corpus","active":[{"id":"0007-DEMO","label":"PB 0007 — Demo corpus","songs":40,
 "remote":true}],"edition":"…"}
```

### 6.2 Stems, beats and buttons

| `t` | Rate | Grant | Content |
| :--- | :--- | :--- | :--- |
| `stems` | on song change | `stems` | the current song's MRPH5 stems: id, MIDI channel, role, musician persona, program, gain and mute, plus the bundle's size and SHA-256. `stems` is an empty list when the song has no bundle |
| `beat` | every beat | `beat` | bar, beat, whether it is a downbeat, tempo, and `at_ms` on the Boll's monotonic clock (use `ping` to learn the offset) |
| `button` | on event | `buttons` | `btn` is `inner`, `middle` or `outer` (ids `BTN_1_PLAY_PAUSE`, `BTN_2_ONLINE_UPLOAD`, `BTN_3_POWER`); `ev` is `tap`, `double`, `triple`, `hold`, `release` or `combo`. The Boll's own button actions still happen |

```json
{"v":1,"t":"stems","song_id":"…","format":"mrph5/1",
 "stems":[{"id":"ch03_benthos","channel":3,"role":"bass","persona":"Benthos","program":33,
           "gain_db":0.0,"mute":false}],
 "bundle_bytes":48213,"sha256":"…"}

{"v":1,"t":"beat","bar":12,"beat":3,"downbeat":false,"bpm":118.0,"at_ms":918273}

{"v":1,"t":"button","btn":"inner","id":"BTN_1_PLAY_PAUSE","ev":"tap"}
```

### 6.3 LED frames

| `t` | Rate | Grant | Content |
| :--- | :--- | :--- | :--- |
| `led` | 60 Hz on USB, ≤ 10 Hz as JSON on BLE | `led` | the frame number and 16 persona colours. With `led_ring`, also `ring`: a hex string of 24 RGB triples, the 16 outer-ring LEDs first, then the 8 inner LEDs |
| `led_bin` | as `led` | `led` | the binary frame below, base64-encoded, for USB docks that subscribe to the `led_bin` topic |
| `matrix` | ≤ 15 Hz on USB, ≤ 5 Hz on BLE, only while subscribed and only when it changes | `matrix` | optional. The frame on the Boll's 13 × 8 LED matrix: `frame`, `t_ms`, `px` (208 hex characters: 104 pixels, row-major, 13 wide, row 0 on top, 0–255) and `rgb` (three colours: LED1, LED3, LED4). A dock with a display can mirror it |

```json
{"v":1,"t":"led","frame":4711,"personas":["#ff3355","…"],"ring":"ff3355…"}
{"v":1,"t":"led_bin","b64":"…"}
```

The binary LED frame is 78 bytes. It is what the optional `DECK_LED` BLE characteristic notifies, and what `led_bin` carries on USB.

| Offset | Size | Field |
| :-: | :-: | :--- |
| 0 | 2 | `frame_no`, unsigned, little-endian |
| 2 | 1 | flags: bit 0 beat, bit 1 downbeat |
| 3 | 1 | brightness |
| 4 | 72 | 24 × (R, G, B): 16 outer-ring LEDs, then 8 inner LEDs |
| 76 | 2 | reserved |

### 6.4 Replies and status

| `t` | When | Content |
| :--- | :--- | :--- |
| `blob` | reply to `get` | the size, SHA-256 and number of chunks that follow |
| `chunk` | after `blob` | one chunk: sequence number and up to 2,048 raw bytes as base64 |
| `remix_status` | on change | the state of a `stem_balance` or `remix` request: `applied`, `queued`, `rendering`, `done` (with `new_song_id`) or `refused` |
| `grants` | when grants change | the full list of current grants (§5.3) |
| `pong` | reply to `ping` | the Boll's monotonic clock in ms |
| `ok` | reply | the request succeeded |
| `error` | reply, or unsolicited | a code from §4.3 and an optional message |

```json
{"v":1,"t":"blob","re":7,"size":48213,"sha256":"…","chunks":24}
{"v":1,"t":"chunk","re":7,"seq":0,"b64":"…"}
{"v":1,"t":"remix_status","re":4,"state":"done","song_id":"…","new_song_id":"…"}
{"v":1,"t":"pong","re":9,"boll_ms":918300}
{"v":1,"t":"ok","re":12}
{"v":1,"t":"error","re":4,"code":"pending_consent","msg":"remix is waiting for the user"}
```

## 7. Dock to Boll messages

### 7.1 Session

| `t` | Grant | Meaning |
| :--- | :--- | :--- |
| `hello` | none | opens the session (§5.1) |
| `subscribe`, `unsubscribe` | the topic's own | add or remove topics, for example `led_ring`, `beat` or `led_bin` |
| `ping` | none | ask for the Boll's clock, to align `at_ms` timestamps |

```json
{"v":1,"t":"subscribe","topics":["led_ring","beat"]}
{"v":1,"t":"unsubscribe","topics":["beat"]}
{"v":1,"t":"ping","id":9}
```

### 7.2 get

`get` fetches a file. The Boll answers with `blob` and then `chunk` messages (§6.4).

| `what` | Fields | Grant |
| :--- | :--- | :--- |
| `mrph5` | `song_id` | `stems` |
| `routine` | `ref`, a storefront reference with a digest | not yet defined (§22) |

```json
{"v":1,"t":"get","id":7,"what":"mrph5","song_id":"…"}
{"v":1,"t":"get","id":8,"what":"routine","ref":"bollawax.org/examplelabs/bass-lean@sha256:…"}
```

### 7.3 Volume and transport

| `t` | Payload | Grant |
| :--- | :--- | :--- |
| `volume` | `set` (0–1), or `step` (+1 or −1, one of 16 steps) | `volume` |
| `transport` | `cmd`: `play`, `pause`, `stop`, `next` or `prev` | `transport` |

```json
{"v":1,"t":"volume","id":12,"set":0.55}
{"v":1,"t":"volume","id":13,"step":1}
{"v":1,"t":"transport","id":14,"cmd":"next"}
```

### 7.4 stem_balance

The Boll plays its songs by synthesizing them from MIDI, so a dock can change how the current song sounds without touching any audio: `stem_balance` re-balances the stems live, and `remix` (§7.5) asks the factory for a new version of the song.

`stem_balance` re-balances the Boll's current playback, stem by stem. It lasts until the song changes, or until the dock sends `"reset": true`. Gains are clamped to −24…+12 dB. The Boll answers with `remix_status` (`applied`). Grant: `stem_balance`.

```json
{"v":1,"t":"stem_balance","id":3,"song_id":"…",
 "stems":{"ch03_benthos":{"gain_db":4.0},"ch06_gallium":{"mute":true}}}
{"v":1,"t":"stem_balance","id":5,"song_id":"…","reset":true}
```

### 7.5 remix

`remix` with `"mode": "render"` asks the factory for a new version of a song. The new version joins the library with the origin `dock:<vendor>/<product>`, and is uploaded like any song, subject to the user's settings. The factory makes it from the song's own plan and the corpora already on the Boll (never a new corpus), and installed routines apply to it as to any song. The Boll reports progress with `remix_status`: `queued`, `rendering`, then `done` with the new song id, or `refused`. Grant: `remix`.

```json
{"v":1,"t":"remix","id":4,"song_id":"…","mode":"render",
 "stems":{"ch03_benthos":{"gain_db":4.0}},"tempo":1.0,"transpose":0,"note":"bass forward"}
```

A `remix` with `"mode": "live"` MUST be treated as `stem_balance`.

### 7.6 Audio routing

Audio itself flows over USB Audio Class 2 or the analog A-BUS. Only the routing goes over the API, and it is **not available over BLE**.

| `dir` | Meaning | Grant |
| :--- | :--- | :--- |
| `from_boll` | the Boll sends its mix to the dock's USB Audio sink (`iface`, `rate`, `ch`) | `audio_out` |
| `to_boll` | the Boll takes the dock's USB Audio source; `use` is `replace`, `mix` or `record` | `audio_in` |
| `abus` | selects the Boll's analog outputs; `route` is `foot`, `medallion` or `both` | see §22 |

```json
{"v":1,"t":"audio","id":5,"dir":"from_boll","iface":"uac2","rate":48000,"ch":2}
{"v":1,"t":"audio","id":6,"dir":"to_boll","use":"mix"}
{"v":1,"t":"audio","id":7,"dir":"abus","route":"foot"}
```

### 7.7 Light sync

`light_sync` either shifts the Boll's lightshow to match the dock's audio latency (`offset_ms`), or lets the dock's own palette of 16 colours drive the Boll's light ring until the dock hands control back. Grant: `light_sync`.

```json
{"v":1,"t":"light_sync","offset_ms":-12}
{"v":1,"t":"light_sync","follow":"dock","palette":["#ff0000","…"]}
{"v":1,"t":"light_sync","follow":"boll"}
```

### 7.8 plugin

`plugin` tells the Boll which published dock plugin the dock is running, so the Boll's UI can show it. No grant is needed.

```json
{"v":1,"t":"plugin","name":"examplelabs/bass-lean","ref":"sha256:…","state":"running"}
```

## 8. Stems and rights

### 8.1 MRPH5 bundles

An MRPH5 bundle is a per-song zip (media type `application/x-mrph5`, zip comment `MRPH5/1`). It is written with fixed timestamps, so the same song always gives the same bytes.

| Entry | Content |
| :--- | :--- |
| `mimetype` | the text `application/x-mrph5`, stored uncompressed, first in the zip |
| `combined.mid` | the full multitrack MIDI, exactly as the song plays |
| `stems/chNN_<label>.mid` | one Standard MIDI File per instrument stem: track 0 is a conductor track with every tempo, time-signature, key-signature, marker and text event; track 1 is that stem's channel messages at their original ticks |
| `meta.yaml` | everything else, machine-friendly (below) |
| `blueprint.md` or `song.json` | the corpus track's blueprint, or the generated song's record |

- Stem names use the MIDI channel as two digits (`ch01` to `ch16`). For a generated song, the label is the musician persona; for a corpus track, it is the track name.
- Playing every file in `stems/` together gives the same notes and tempo map as `combined.mid`.
- The proposed stem **roles** are `ch01` lead, `ch02` harmony, `ch03` bass and `ch10` drums, with the other channels named by persona. This mapping is a proposal until MRPH5 is frozen.
- `meta.yaml` includes the format and kind, title, tempo, time signature, key, the part list (bars, start and end times, active channels, chords), the channel table, the MIDI tempo, time-signature and key maps, groove descriptors (swing ratio, syncopation per stem, note density, velocity arc, kick and snare pattern) and statistics per stem. Every time is given in both ticks and seconds.

### 8.2 Who gets which stems

- The Boll hands out stems of **songs it generated** once the user grants `stems`.
- It hands out stems of **corpus songs** only where the corpus allows it (a proposed per-corpus `dock_stems` setting).
- A dock **never** receives corpus audio in any form, or the Boll's account tokens or credentials. A request for them fails with `corpus_rules`.

### 8.3 MIDI-only corpora

Some corpora are **MIDI-only**: the artist licensed Pop Boll to transcribe the recordings but not to keep or play them, so no audio exists for them anywhere in Pop Boll, not on the Boll and not on the service. A dock or a Bolltridge must not assume that a corpus has recordings.

- Each entry of the `corpus` message (§6.1) carries `"audio": "licensed"` or `"audio": "not_licensed"`. A dock that finds no `audio` field must treat the corpus as `"not_licensed"`.
- Everything a dock or cartridge can get for a corpus song, MRPH5 bundles and their stems included, is made from the corpus's MIDI transcriptions. It works the same for MIDI-only corpora.
- A player cartridge that wants corpus recordings plays nothing for a MIDI-only corpus and should say so ("MIDI-only corpus"). Asking for its audio fails with `corpus_rules`, as for any corpus.

## 9. Versioning

- The envelope's `v` changes only if the framing changes.
- The protocol is `bollardeck/<major>`. `hello` lists every major the dock speaks, and the Boll picks the highest one in common, or answers `no_common_api`.
- Within a major version, additions are new optional fields, new message types, new error codes and new capability flags, discovered through `want`, `can` and `grants`. Removing a field or changing its meaning requires a new major version.
- The physical specification and this API move independently. A Bollard v1.0 dock speaks `bollardeck/1`.

## 10. Example dock

**Bass Lean** is a minimal dock: a Linux single-board computer on the USB-C profile that runs as a USB gadget, whose CDC-ACM function (interface string `bollardeck`) appears to it as `/dev/ttyGS0`. It pushes the bass forward on every song. It needs pyserial.

```python
#!/usr/bin/env python3
import json
import serial

port = serial.Serial("/dev/ttyGS0", timeout=1)
PLAN = {"bass": +4.0, "drums": +1.5, "lead": -2.0}   # dB per stem role


def send(t, **payload):
    port.write((json.dumps({"v": 1, "t": t, **payload}, separators=(",", ":")) + "\n").encode())


send("hello", id=1, api=["bollardeck/1"],
     dock={"vendor": "Example Labs", "product": "Bass Lean", "profile": "usb-c", "fw": "0.1"},
     want=["nowplaying", "stems"], can=["stem_balance"])

next_id = 2
for raw in port:
    if not raw.strip():
        continue
    msg = json.loads(raw)
    if msg["t"] in ("welcome", "grants"):
        print("balance allowed" if "stem_balance" in msg.get("grants", []) else "waiting for consent")
    elif msg["t"] == "stems" and msg["stems"]:
        gains = {s["id"]: {"gain_db": PLAN[s["role"]]} for s in msg["stems"] if s.get("role") in PLAN}
        if gains:
            send("stem_balance", id=next_id, song_id=msg["song_id"], stems=gains)
            next_id += 1
    elif msg["t"] == "error":
        print("boll:", msg.get("code"), msg.get("msg", ""))
```

## 11. Part B: Routines

A **routine** is a small piece of Python that changes how your Pop Boll writes songs. It hooks into the song factory on the Boll, runs only on **your own Boll**, and works only with the corpora already on the device.

How a song is made, so the hooks make sense:

1. A **planning summit** plans the song (a single, or a multi-movement suite): key, tempo, and a list of parts (Intro, Verse, Chorus and so on), each with a chord progression, an energy level and a length in bars.
1. A band of virtual musicians **jams each part in takes**.
1. After each take, **seven judges** score it. When they vote to lock a take, the part is done. A take ceiling makes sure every part finishes.
1. When every part is locked, the band votes on **overdubs**.
1. The song is **exported** as MIDI and a song record.
1. A device can also render a **making-of film** from the session.

### 11.1 What a routine can never do (normative)

- **No network.** No sockets, HTTP, DNS or subprocesses.
- **No file writes outside its own folder.** A folder routine may write inside its folder. A zipped routine may write inside a `<zip name>.data/` folder next to the zip.
- **No corpus creation or change.** No API creates a corpus, by design, and corpora are read-only to routines. A routine sees corpus labels, summaries and musical DNA through frozen views, and never gets a handle or a path to a corpus.
- **No score forging.** A routine can read every judge's score, and can add directives or veto a lock. It cannot change a score, a vote or the lock rule.
- **No hidden state across songs that matters to the factory.** A routine's module stays loaded for the process, but everything it returns is validated on every call.

## 12. Hooks

A routine defines one or more of these as plain module-level functions in `routine.py`.

| Hook | Called | May | May not |
| :--- | :--- | :--- | :--- |
| `on_plan(plan, ctx)` | once per song, right after the planning summit | re-order the parts of a single; change each part's progression, energy, bars and label | add or remove parts; change any other plan or part field; re-order the parts of a suite |
| `on_part_candidate(part, notes, ctx)` | every take of every part, after synthesis and autotune, before the judges | return a transformed note list: add, remove or change notes, which are then judged as they are | exceed 2 × the input notes + 512; produce a note outside the part or outside MIDI ranges |
| `on_verdict(part, verdicts, ctx)` | every take, after the judges | add up to 4 directive codes to the next take; veto a lock | change a score, a vote or a threshold |
| `on_overdub(plan, bus, ctx)` | once, after the band's overdub votes | add notes to locked parts (≤ 512 per part) | remove or change locked notes |
| `on_export(meta, ctx)` | once, before the song record is written | return a JSON mapping (≤ 16 KiB) stored under the routine's name | change any other part of the record |
| `on_drama(shots, ctx)` | when a device renders the making-of film | re-order shots; add up to 32 shots | remove or alter an original shot |

### 12.1 on_plan

- **Receives** a copy of the song plan. Return the plan, or `None` to keep the edits you made to the copy in place.
- **May** re-order the parts of a single (not a suite), and change each part's `progression` (1–8 chords from `I`, `ii`, `iii`, `IV`, `V`, `vi` and `bVII`), `energy` (0–1), `bars` (a multiple of 4 from 4 to 32) and `label` (truncated to 40 characters). `part_index` is renumbered in the new order, and the plan's `total_bars` is recomputed.
- **May not** add or remove parts, change any other plan field (key, tempo, teams, taste, corpora, title and so on) or any other part field (category, assigned teams, study-break channel, movement, leitmotif role).
- A result that breaks any of these rules is rejected as a whole, and the routine is disabled for the rest of the song.

### 12.2 on_part_candidate

- **Receives** a copy of the part and a list of copied note events.
- **Returns** a new list, or `None` after editing the list in place. The routine owns those notes, and the judges hear them as they are.
- **Limits:** at most 2 × the input count + 512 notes, and every note valid: `channel_1based` 1–16, `pitch` 0–127, `velocity` 1–127, `duration_ticks` greater than 0, and `start_tick` from 0 up to (not including) `bars` × 1,920.

### 12.3 on_verdict

- **Receives** the part and the seven judges' verdicts as frozen views.
- **Returns** `None`, or `{"directives": [...], "veto": bool, "reason": str}`.
- **Directives:** up to 4 codes matching `^[A-Z][A-Z0-9_]{2,47}$`, added to the next take of the part (§12.7).
- **Veto:** stops the take from locking, so the band goes again. A veto expires at the take ceiling, so a song always finishes.
- The views are frozen: writing to one raises an error and disables the routine.

### 12.4 on_overdub

- **Receives** a copy of the plan and a frozen view, `{"locked_parts": {part_index: (note, …)}}`.
- **Returns** `{part_index: [note, …]}`: notes to **add**, at most 512 per part. They are tagged with `origin_tag` `routine:<name>` and listed in the song's overdub record.
- Locked notes cannot be removed or changed.

### 12.5 on_export

- **Receives** the song's metadata, frozen.
- **Returns** a JSON-serialisable mapping of at most 16 KiB, stored in the song record at `routine_meta.<name>`.

### 12.6 on_drama

- **Receives** a copy of the making-of film's shot list. It runs only on devices.
- **May** re-order shots, and add up to 32 shots of the form `{"kind": str, "duration_ms": 180–20000, "data": {}}`, which are marked `by_routine`. Timings are recomputed.
- **May not** remove or alter an original shot.

### 12.7 Directive codes

Directives are advisory codes that the next take of a part reads. The factory currently **acts on** these:

| Code | Effect on the next take |
| :--- | :--- |
| `EVOLVE_COUNTERPOINT_AND_VELOCITY_ARC` | develop counterpoint and the velocity arc |
| `EVOLVE_CALL_AND_RESPONSE_DUEL` | develop call-and-response between players |
| `DUCK_COMPING_UNDER_LEAD_MELODY` | keep accompaniment under the lead melody |
| `REINFORCE_BEAT_2_4_BACKBEAT` | reinforce the backbeat on 2 and 4 |
| `ADD_KICK_SNARE_FOUNDATION` | add a kick and snare foundation |
| `REDUCE_CHROMATIC_QUIRKS` | reduce chromatic notes |
| `AUTOTUNE_SNAP_SOUR_NOTES` | snap sour notes to the scale |
| `TIGHTEN_RHYTHMIC_POCKET_GRID` | tighten timing to the grid |

Any other well-formed code is recorded with the take and carried into the next take's directive set, but it does not change the notes until the factory learns it. The `MORE_HOOK` code in the `judge_heckler` example (§15.2) is one of these.

### 12.8 Data types

**Parts** (in `plan.parts`, and as the `part` argument):

| Field | Type | Editable in `on_plan` |
| :--- | :--- | :-: |
| `part_index` | int, 1…N | renumbered |
| `label` | str, for example "Verse 1" | yes |
| `category` | `Intro`, `Verse`, `Chorus`, `Bridge`, `Instrumental Break` or `Outro` | no |
| `bars` | int, a multiple of 4 | yes |
| `progression` | list of chord symbols, for example `["I", "V", "vi", "IV"]` | yes |
| `energy` | float, 0–1 | yes |
| `assigned_team_ids`, `study_break_channel`, `movement_index`, `leitmotif_role` | band and suite bookkeeping | no |

The plan also carries read-only fields such as `song_id`, `title`, `bpm`, `key_str`, `form` (`single` or `suite`), `corpus_ids`, `corpus_labels` and `total_bars`.

**Note events:**

| Field | Meaning |
| :--- | :--- |
| `channel_1based` | MIDI channel, 1–16 |
| `pitch` | MIDI note number, 0–127 |
| `start_tick` | offset from the start of the part; 480 ticks per quarter note, 1,920 per bar |
| `duration_ticks` | length in ticks |
| `velocity` | 1–127 |
| `origin_tag` | where the note came from, for example `jam`, `overdub` or `routine:<name>` |
| `was_autotuned`, `quirk_waiver_granted` | factory bookkeeping |

To make a new note, copy one you received with `dataclasses.replace(note, pitch=…, start_tick=…)`.

**Verdict views** (frozen): `judge_id` (a string that starts with the judge's code, such as `J2` for the Melodic Hook judge), `judge_number`, `domain`, `score`, `threshold`, `lock_vote`, `metrics` (a mapping of names to floats) and `directives` (a tuple of codes).

## 13. Context and limits

### 13.1 The context

| Attribute | What | Writable |
| :--- | :--- | :--- |
| `ctx.routine_name`, `ctx.routine_version`, `ctx.api` | who is running, and the API version (1) | no |
| `ctx.hook`, `ctx.song_id`, `ctx.part`, `ctx.take` | where in the song: `part` is a frozen summary, and `take` is the 1-based take for per-take hooks | no |
| `ctx.corpus` | a frozen view with `ids`, `labels`, `summaries` and `dna` (the merged musical DNA of the active corpora). There is no handle to a corpus | no |
| `ctx.roster` | `{"musicians": {channel: …}, "judges": {judge_id: …}}`, the evolving roster, frozen | no |
| `ctx.taste_dna` | the song's taste profile, frozen | no |
| `ctx.device` | `{"serial", "song_id_prefix"}`; never probes hardware | no |
| `ctx.rng` | a `random.Random` seeded from the song seed, the routine name, the hook, the part and the take: deterministic per song, and separate from the factory's own random numbers, so a routine never shifts the band's dice | yes (it is the routine's own) |
| `ctx.log(msg)` | one line (≤ 200 characters, ≤ 50 lines per song) into the song record | append-only |
| `ctx.folder` | the routine's own folder, the only place it may write | yes |

### 13.2 Limits

A manifest's `limits` can only tighten these caps.

| Limit | Hard cap |
| :--- | :--- |
| `max_ms_per_call` | 250 ms of wall time, measured after the call (Python cannot pre-empt it) |
| `max_directives_per_verdict` | 4 |
| `max_notes_factor` | 2.0 (× the input notes, + 512) |
| `max_overdub_notes_per_part` | 512 |
| `max_export_bytes` | 16,384 |
| `max_added_shots` | 32 |
| `max_log_lines` | 50 |

### 13.3 Failures and the song record

Every hook call is wrapped. **An exception, a guard refusal, a result that breaks the hook's rules, or a call slower than the time limit disables that routine for the rest of the song.** Its results from that call are discarded, and the song carries on. The song record shows what happened:

```json
"routines_applied": [
  {"name": "mixolydian_bridge", "version": "0.1.0", "author": "Pop Boll examples",
   "source": "/data/routines/mixolydian_bridge", "hooks_called": {"on_plan": 1},
   "log": ["part 5 Bridge: vi-iii-IV-V -> I-bVII-IV-I"]}
],
"routines_failed": [
  {"name": "boom", "version": "0.0.1", "hook": "on_part_candidate",
   "where": "on_part_candidate part 1 take 2", "error": "ValueError: kaboom"}
],
"routine_meta": {"calm": {"ok": 1}}
```

- `routines_failed` also lists packages that failed to load (with `"hook": "load"`): a bad manifest, a banned import or a duplicate name.
- Each part's take history also records the directives routines added, their vetoes, and whether a lock was vetoed.
- **With no routine installed, none of these keys exist, and the song is byte-identical to one made by a factory without hooks.**
- The record survives pausing and resuming a song.

## 14. Packaging and installation

### 14.1 Package layout

A routine is a folder, or a `.zip` of one (with the files at its root or inside one top-level folder):

```text
mixolydian_bridge/
  routine.yaml     manifest (YAML; JSON also parses)
  routine.py       the hooks, as plain module-level functions
  README.md        optional
```

### 14.2 Manifest

```yaml
name: mixolydian_bridge          # ^[a-z0-9][a-z0-9_.-]{1,63}$, unique on the device
version: 0.1.0
author: Pop Boll examples
api: 1                           # the routine API version
description: Every Bridge plays the Mixolydian jam cell.
hooks: [on_plan]                 # each MUST be defined in routine.py
order: 100                       # optional; lower runs first
limits: {max_ms_per_call: 20}    # optional; can only be tighter than the hard caps
```

### 14.3 Load order

1. The Boll loads routines from the `routines/` folder of its data directory, in name order. Further search paths can be configured on the device; each is one routine or a folder of routines, read in the order given.
1. The first routine with a given `name` wins. Later ones become `duplicate name` load failures.
1. The loaded routines run in order of (`order`, discovery position), and every hook calls them in that order. Each routine sees the results of the routines before it.
1. A device setting turns all routines off.

Routines are picked up when the next song starts.

### 14.4 Installing on a Boll

Copy the routine's folder, or a zip of it, into the `routines/` folder of the Boll's data directory. Today that means a computer connected to the Boll's medallion USB-C port, which gives the computer a network link to the Boll. A friendlier install flow, and installs from the storefront (§21), are planned. Installing a routine is always the owner's act, on their own device.

## 15. Example routines

### 15.1 mixolydian_bridge

`on_plan`: every Bridge's progression becomes the Mixolydian jam cell **I – bVII – IV – I** (in C: C, B♭, F, C), and each change is logged. Nothing else in the plan changes.

```yaml
# A bollardeck routine. Its "bollawax file" is this folder, or a zip of it.
name: mixolydian_bridge
version: 0.1.0
author: Pop Boll examples
api: 1
description: >
  Every Bridge plays the factory's Mixolydian jam cell (I - bVII - IV - I) instead of the
  progression the planning summit picked. Nothing else about the plan changes.
hooks: [on_plan]
limits:
  max_ms_per_call: 20
```

```python
"""mixolydian_bridge: force every Bridge onto the Mixolydian jam cell and log it.

The cell is the "corpus quirk: Mixolydian Jam Break" progression of the factory's
Instrumental Break table, I - bVII - IV - I (in C: C, Bb, F, C).
"""

MIXOLYDIAN_JAM_CELL = ["I", "bVII", "IV", "I"]


def on_plan(plan, ctx):
    for part in plan.parts:
        if part.category == "Bridge" and list(part.progression) != MIXOLYDIAN_JAM_CELL:
            ctx.log(f"part {part.part_index} {part.label}: {'-'.join(part.progression)} -> "
                    f"{'-'.join(MIXOLYDIAN_JAM_CELL)}")
            part.progression = list(MIXOLYDIAN_JAM_CELL)
    return plan
```

### 15.2 judge_heckler

`on_verdict`: when J2 (the Melodic Hook judge) scores a take below 0.6, it adds the directive `MORE_HOOK`. It never changes a score, and every take's scores and votes stay identical to a run without it. `MORE_HOOK` is not yet a code the factory acts on, so it is a heckle you can see in the song record: it does not change the music.

```yaml
name: judge_heckler
version: 0.1.0
author: Pop Boll examples
api: 1
description: >
  Heckles from the back of the room: whenever J2 (Melodic Hook) scores a take below 0.6 it
  adds a MORE_HOOK directive for the next take. It never changes a score or a vote.
hooks: [on_verdict]
limits:
  max_ms_per_call: 20
  max_directives_per_verdict: 1
```

```python
"""judge_heckler: shout MORE_HOOK whenever J2's score is below 0.6. Scores stay untouched.

Verdicts arrive as frozen views, so a routine cannot forge a score even by accident; the
only things on_verdict can hand back are directives, a veto and a reason.
"""

THRESHOLD = 0.6


def on_verdict(part, verdicts, ctx):
    j2 = next((v for v in verdicts if v.judge_id.startswith("J2")), None)
    if j2 is None or j2.score >= THRESHOLD:
        return None
    ctx.log(f"part {part.part_index} take {ctx.take}: J2 {j2.score:.3f} < {THRESHOLD} -> MORE_HOOK")
    return {"directives": ["MORE_HOOK"], "reason": f"J2 scored {j2.score:.3f}"}
```

## 16. Security

**A routine is ordinary Python running inside the song factory on your Boll.** The factory enforces the rules of §11.1 in three layers:

1. **Before loading**, statically: imports of network, process and foreign-function modules are refused (`socket`, `ssl`, `http`, `urllib`, `requests`, `httpx`, `aiohttp`, `asyncio`, `subprocess`, `multiprocessing`, `ctypes`, `cffi` and similar), and so are imports of the factory's own corpus and device modules.
1. **During each hook call**, at run time: a Python audit hook refuses socket, subprocess, `os.system`, exec, spawn, fork and `ctypes` events, and any open-for-write, remove, rename, mkdir or chmod outside the routine's folder.
1. **On every result:** the factory validates what comes back and keeps only what each hook is allowed to change.

That is a **guard rail for honest code, not a sandbox** against hostile code. Python offers no in-process sandbox, and a determined routine can get around audit hooks. Install only routines you have read, or that come from someone you trust.

For the same reason:

- **Routines run only on your own Boll.** The website never runs user routines: its song factory loads none, whatever is installed.
- **No API creates corpora.** Routines work with the corpora on the device, and cannot add, change or export them.
- **A curated path for the web** (routines reviewed by a person and allowlisted, or routines compiled to WebAssembly and run in a WASI sandbox with only the hook payloads as input) is a possible future path, and is not built. Until something like it exists, the website stays routine-free.

## 17. Part C: Bolltridges

A **Bolltridge** is a cartridge for the Pop Boll: a Bollard device with its own Linux computer that runs one approved cartridge image (a synth, a sequencer, a soundfont pack, an effect, a player, a remixer or a visualiser). Its hardware, power, audio and chaining rules are in [Bollard §17](bollard.md#17-bolltridge-device-class). This part is the message set. **Everything in Part A still applies**: a Bolltridge is a dock that also says `"kind": "bolltridge"`.

### 17.1 Transports

- **USB CDC-ACM** with the interface string `bollardeck` is REQUIRED, on the Full profile as an active-USB module or on the USB-C profile. There is one CDC session per cartridge.
- **USB-MIDI** is OPTIONAL. If present, the Boll binds it by the interface string `bolltridge-midi` and uses it for **live** MIDI only (§19.3). Song MIDI always travels as JSON, because USB-MIDI carries no timestamps.
- **USB Audio Class 2** carries audio. Only its routing goes over the API (§19.5).
- **BLE** MAY carry the control subset (`caps`, `params`, `param_set`, `health`, `clock`). It MUST NOT carry `midi`, `voice_claim`, audio routing or image updates.
- **Heartbeat.** `health` at 1 Hz is the heartbeat (§19.6). After 3 s without one, the Boll treats the cartridge as crashed: it drops its audio, returns any channels it had taken over, and shows it as "not responding".

### 17.2 On the cartridge

- The session is owned by a proxy in the cartridge's system container. Image code speaks the same JSON lines on the Unix socket `/run/bollardeck/cart.sock`.
- The proxy adds `hello`, `health` and the image-update messages itself, and refuses attempts by image code to send them, so an image cannot forge the system's identity.
- The cartridge base image ships client libraries: for Python, `import bollardeck` gives `bollardeck.Cartridge`, an asyncio client with typed messages and `on_clock`, `on_midi`, `on_stems`, `on_voice` and `on_power_low` callbacks; for C and real-time engines, `libbollardeck` with `bollardeck.h`. Their API reference will be published with the base image.

## 18. Cartridge session

### 18.1 hello and welcome

```json
{"v":1,"t":"hello","id":1,"api":["bollardeck/1"],
 "dock":{"kind":"bolltridge","vendor":"Example Labs","product":"TR-Boll Puck",
         "vid":4617,"pid":49170,"profile":"full","fw":"0.1",
         "url":"https://example.org/tr-boll"},
 "cart":{"spec":"0.1",
         "image":"bollawax.org/examplelabs/tr-boll:1.2.0",
         "digest":"sha256:4f1c…","version":"1.2.0","name":"examplelabs/tr-boll",
         "base":"bollawax-cartridge-base:0.1@sha256:9ab0…",
         "sig":{"type":"cosign-bundle","b64":"…"},
         "manifest":"sha256:77d2…","affirmation":"aff_01JC6ZK3W0R5",
         "compute":{"board":"rpi-zero-2w","tier":1,"ram_mb":512},
         "power":{"profile":"self","avg_mw":2600,"peak_mw":3500,"vbus_source_ma":1500,
                  "vstack_source_ma":0},
         "audio":{"usb":true,"abus_mix":true,"latency_ms":10},
         "chain":{"position":0,"female_bottom":true}},
 "want":["state","nowplaying","clock","midi_in","beat"],
 "can":["voice","midi_out","audio_usb","abus_mix","cart_params"]}

{"v":1,"t":"welcome","re":1,"api":"bollardeck/1",
 "boll":{"model":"Pop Boll Rev B","fw":"…","serial":null},
 "cart":{"verified":true,"hacker_mode":false,"enabled":false,"chain_depth":2,
         "power_ok":true,"return":"usb","lookahead_ms":30},
 "grants":["state","nowplaying","clock","beat","midi_out","audio_usb","cart_params"],
 "pending":["midi_in","voice"],
 "limits":{"midi_batch_hz":100,"max_line":65536}}
```

The `cart` block carries the image reference and digest, the version, the signature bundle, the base image, the manifest digest, the storefront affirmation record, and the compute, power, audio and chain declarations. Registry references in these examples are placeholders until the storefront exists.

### 18.2 Rules

- `cart.chain.position` and the chain depth MUST match what CHAIN enumeration found, and `cart.power` and `cart.audio` MUST match the `bolltridge` object in the Bollard JSON descriptor. A mismatch gets `identity_mismatch`, as for the vendor and product IDs. A USB-C-profile cartridge has no CHAIN and reports position 0.
- `welcome.cart.verified` is the Boll's offline check of `sig` against the signing keys it carries. An unverified image gets the `unsigned_image` error and only the `state` grant, unless the Boll is in hacker mode ([Bollard §17.11](bollard.md#17-bolltridge-device-class)): then `verified` is `false`, `hacker_mode` is `true`, and normal grants follow.
- `enabled` stays `false` until the power budget allows the cartridge and the user has not disabled its image. The Boll then sends `cart_enable` (§19.2).
- `lookahead_ms` is how far ahead the Boll sends song MIDI: the cartridge's declared `latency_ms` + 20 ms.
- `return` is the audio return path the Boll has selected: `usb`, `abus` or `none` (§19.5).

### 18.3 Capability flags for cartridges

These add to the flags of §5.4.

| Flag | Direction | Default | Meaning |
| :--- | :--- | :--- | :--- |
| `clock` | read | granted | transport and tempo map (§19.2) |
| `midi_in` | read | granted for generated songs, **consent** for corpus songs | the current song's MIDI, streamed ahead of time (§19.3) |
| `midi_out` | write | granted | cartridge MIDI into the Boll: played by its synthesizer, recorded, or both |
| `voice` | write | **consent** | take over one or more song channels (§19.4) |
| `audio_usb` | write | granted (mixed at ≤ 0 dB; the Boll's volume applies) | the cartridge's USB Audio output into the Boll's mix |
| `audio_in` | read | **consent** (as in Part A) | the Boll's mix, or one stem, sent down to the cartridge over USB Audio |
| `abus_mix` | write | granted when declared | the cartridge may be selected as the analog mix-through return (§19.5) |
| `stems` | read | **consent** (as in Part A; corpus rules apply) | MRPH5 bundles, offered or requested (§19.7) |
| `cart_params` | both | granted | the Boll's UI shows and sets the cartridge's parameters (§19.1) |
| `cart_ota` | read | granted when verified or in hacker mode | image updates over the link (§19.8) |

## 19. Cartridge messages

### 19.1 What the cartridge declares

| `t` | Direction | When | Content |
| :--- | :--- | :--- | :--- |
| `caps` | cartridge → Boll | after `welcome`, and on change | instruments, patches, soundfonts, sequencer, effects, the maximum number of stems it takes, and any visual output. It mirrors the `caps` section of the manifest |
| `params` | cartridge → Boll | after `caps`, and whenever a value changes on the cartridge | its parameters. Types: `float`, `int`, `bool`, `enum`, `multi` and `trigger` |

```json
{"v":1,"t":"caps","instruments":[{"id":"kit","name":"TR-Boll kits","channels":[10],"programs":3,
 "polyphony":16,"gm_map":"drums"}],"patches":[{"id":"boll-909","name":"Boll 909"}],
 "soundfonts":[{"file":"hall_strings.sf3","programs":18}],
 "sequencer":{"tracks":8,"ppq":96,"patterns":64},
 "effects":[{"id":"grain","type":"granular","inputs":["stems","boll_mix"]}],
 "max_stems":4,"visual":null}

{"v":1,"t":"params","params":[{"id":"swing","type":"float","min":0,"max":0.6,"value":0.12},
 {"id":"kit","type":"enum","values":["boll-909","boll-808","dusty"],"value":"boll-909"}]}
```

The Boll MAY show at most 16 parameters in its own UI. The rest appear on the cartridge's page in the Boll's local web UI.

### 19.2 Control and clock

| `t` | Direction | Rate | Grant | Meaning |
| :--- | :--- | :--- | :--- | :--- |
| `cart_enable` | Boll → cartridge | once | — | the cartridge may now draw what it declared and leave mute |
| `cart_disable` | Boll → cartridge | on event | — | `reason` is `power_budget`, `user`, `crashed`, `thermal`, `chain_depth` or `unsigned_image`. The cartridge mutes, bypasses A-BUS, releases any channels it took over and stays connected |
| `clock` | Boll → cartridge | on every transport change, at each bar, and every 2 s | `clock` | transport and tempo map, with the next change ahead, so a sequencer can plan bars in advance |
| `param_set` | Boll → cartridge | on user action | `cart_params` | set a parameter; the cartridge answers `ok` and re-sends `params` |
| `abus_ready` | Boll → cartridge | on request | `abus_mix` | A-BUS is ramped down: switch the relay now (§19.5) |
| `health_req` | Boll → cartridge | rarely | — | asks for an immediate `health` |

```json
{"v":1,"t":"cart_enable","power_ma":700,"priority":3}
{"v":1,"t":"cart_disable","reason":"power_budget"}
{"v":1,"t":"clock","song_id":"…","playing":true,"bpm":118.0,"time_sig":"4/4","bar":12,"beat":3,
 "tick":0,"ppq":96,"at_ms":918273,"next_change":{"bar":17,"bpm":118.0,"part":"Chorus"}}
{"v":1,"t":"param_set","id":31,"param":"swing","value":0.2}
{"v":1,"t":"abus_ready","re":40}
{"v":1,"t":"health_req","id":44}
```

Part A's `beat` keeps flowing; `clock` adds the tempo map. On the Full profile the hardware SYNC line gives sub-millisecond beat edges, and `ping` and `pong` align `at_ms`.

### 19.3 MIDI

| `t` | Direction | Grant | Meaning |
| :--- | :--- | :--- | :--- |
| `midi` | Boll → cartridge | `midi_in` (only the channels the cartridge claimed or subscribed to) | song MIDI, each event `[at_ms, status, data1, data2]` on the Boll's clock, sent `lookahead_ms` early in batches of at most 10 ms of music. `{"flush": true}` on seek, stop or song change |
| `midi` | cartridge → Boll | `midi_out` | cartridge MIDI. `use` is `play` (the Boll's synthesizer renders it), `record` (it joins the song as an overdub on the next `remix`) or `both`. An `at_ms` in the past plays immediately |
| `subscribe` | cartridge → Boll | `midi_in` | listen to channels the cartridge did not claim: topics `midi:ch<N>` or `midi:all` |

```json
{"v":1,"t":"midi","song_id":"…","ch":[2,10],
 "ev":[[918300,144,38,96],[918300,153,36,110],[918420,128,38,0]]}
{"v":1,"t":"midi","flush":true}
{"v":1,"t":"midi","id":52,"use":"play","ev":[[918600,153,42,80]]}
{"v":1,"t":"subscribe","topics":["midi:ch10","midi:all"]}
```

Rates: at most 100 `midi` lines per second in each direction, and at most 64 events per line. Live MIDI on the optional USB-MIDI function has no timestamps and plays on arrival.

### 19.4 Voice takeover

A cartridge can replace, or layer over, the Boll's own synthesizer voice for a song channel. The Boll then mutes that channel locally, streams its MIDI to the cartridge, and mixes the cartridge's audio back (over USB Audio), or lets it sum on A-BUS.

| `t` | Direction | Grant | Meaning |
| :--- | :--- | :--- | :--- |
| `voice_claim` | cartridge → Boll | `voice` | claim channels; `mode` is `replace` or `layer` |
| `voice_ok` | Boll → cartridge | | the takeover starts at the next bar, so it is seamless |
| `voice_release` | either | | the Boll's own voice returns at the next beat |

```json
{"v":1,"t":"voice_claim","id":60,"channels":[2],"mode":"replace",
 "program":"hall:strings_legato"}
{"v":1,"t":"voice_ok","re":60,"channels":[2],"from_bar":13}
{"v":1,"t":"voice_release","channels":[2]}
```

- At most 4 claimed channels per cartridge, and one owner per channel: a second claim gets `voice_busy`.
- On a crash, a timeout, a `bypass` or an unplug, the Boll **returns every claimed channel to its own voice within one beat**, so the song never goes silent.
- A claim is for playback. The exported song keeps the Boll's own rendering (§22).

### 19.5 Audio routing

The Boll selects one return path per cartridge: `usb` (the default), `abus` (analog mix-through, only if the cartridge declared `abus_mix`) or `none`.

| `t` | Direction | Grant | Meaning |
| :--- | :--- | :--- | :--- |
| `audio` | Boll → cartridge | `audio_usb` or `abus_mix` | selects the return path and gain. With `abus`, `abus_max_vrms` is 1.0 divided by the number of mixing cartridges |
| `audio` | cartridge → Boll | `audio_in` | asks the Boll to send its mix (`source: "mix"`) or one rendered stem (`source: "stem:<id>"`) down over USB Audio |
| `abus_switch` | cartridge → Boll | `abus_mix` | asks to switch the relay to `mix` or `bypass`. The Boll ramps A-BUS down over 20 ms, answers `abus_ready`, then ramps back up 30 ms later |
| `bypass` | cartridge → Boll | — | the supervisor has already put the relay in bypass; `reason` is `watchdog`, `power`, `thermal`, `user` or `update` |

```json
{"v":1,"t":"audio","id":70,"return":"usb","gain_db":0.0}
{"v":1,"t":"audio","id":72,"return":"abus","gain_db":-3.0,"abus_max_vrms":0.5}
{"v":1,"t":"audio","id":71,"dir":"from_boll","source":"stem:ch05_pad"}
{"v":1,"t":"abus_switch","id":40,"to":"mix"}
{"v":1,"t":"bypass","reason":"watchdog"}
```

### 19.6 Health and thermal

`health` is sent by the cartridge at 1 Hz and is its heartbeat. `power` is `ok`, `low` or `critical`.

```json
{"v":1,"t":"health","uptime_s":3600,"soc_c":58.5,"board_c":41.0,"cpu_pct":63,"mem_mb":301,
 "xruns":0,"throttled":false,"power":"ok","input_mv":5080,"draw_mw":2480,"voices":14,
 "image":"sha256:4f1c…"}
```

If `soc_c` is at least 80 °C, or `throttled` is true, for 10 s, the Boll asks the cartridge to shed load by setting the reserved parameter `_quality` to `low` with `param_set`. At 85 °C or more it sends `cart_disable` with the reason `thermal`.

### 19.7 Stems for cartridges

| `t` | Direction | Grant | Meaning |
| :--- | :--- | :--- | :--- |
| `stem_offer` | Boll → cartridge | `stems` | the Part A `stems` payload plus an `offer` id, sent on song change to cartridges whose `caps.max_stems` is above 0 |
| `stem_request` | cartridge → Boll | `stems` | asks for named stems or `"all"`. The Boll answers with `blob` and `chunk` (§6.4), carrying the MRPH5 bundle, or only the requested stems' MIDI files plus `meta.yaml` |

```json
{"v":1,"t":"stem_offer","offer":81,"song_id":"…","format":"mrph5/1",
 "stems":[{"id":"ch05_pad","channel":5,"role":"pad","persona":"…","program":89,"gain_db":0.0,
           "mute":false}],"bundle_bytes":48213,"sha256":"…"}
{"v":1,"t":"stem_request","id":82,"song_id":"…","stems":["ch05_pad","ch01_lead"]}
```

At most `caps.max_stems` stems per song. Corpus songs follow the same corpus rules as docks (§8.2), and the error is `corpus_rules`. A cartridge receives MIDI stems only, never audio of corpus songs.

### 19.8 Image updates over the link

The Boll fetches images from the storefront with its own short-lived token and relays them, so a cartridge never needs a registry credential, or even Wi-Fi.

| `t` | Direction | Meaning |
| :--- | :--- | :--- |
| `image_offer` | Boll → cartridge | a new image: name, version, digest, size, number of chunks, signature bundle, and when to apply it (`now`, `next_song` or `next_boot`) |
| `image_chunk` | Boll → cartridge | one chunk, up to 2,048 raw bytes as base64. The cartridge MAY pace the transfer with `ota_status` `receiving` and the next sequence number it wants |
| `ota_status` | cartridge → Boll | `receiving`, `verifying`, `installed`, `switched`, `rolled_back` or `failed` (with `error`) |

```json
{"v":1,"t":"image_offer","id":90,"name":"examplelabs/tr-boll","version":"1.3.0",
 "digest":"sha256:…","size":187654321,"chunks":91629,
 "sig":{"type":"cosign-bundle","b64":"…"},"apply":"next_song"}
{"v":1,"t":"image_chunk","re":90,"seq":0,"b64":"…"}
{"v":1,"t":"ota_status","re":90,"state":"receiving","next":1}
{"v":1,"t":"ota_status","re":90,"state":"verifying"}
```

Over USB at an effective 4–8 MB/s (estimate), a 600 MiB image takes about 1.5–2.5 minutes. The cartridge verifies the signature before loading, and keeps its previous image until the new one has sent three heartbeats.

### 19.9 Errors added by Part C

| Code | Meaning |
| :--- | :--- |
| `unsigned_image` | the image has no valid signature and the Boll is not in hacker mode |
| `power_budget` | enabling the cartridge would exceed the stack's power budget |
| `chain_depth` | a fifth Bolltridge in the chain, or deeper than USB allows |
| `thermal` | the cartridge is too hot |
| `voice_busy` | the channel is already claimed by another cartridge |
| `ota_failed` | an image update failed |
| `tier_too_low` | the image needs a bigger compute tier than the board reports |

## 20. Cartridge manifest

Every cartridge image carries a `cartridge.yaml` at its root (and as an image label).

```yaml
bolltridge: 0.1                      # the Bolltridge extension version
name: examplelabs/tr-boll            # ^[a-z0-9][a-z0-9._-]{1,31}/[a-z0-9][a-z0-9._-]{1,63}$
developer: {name: Example Labs, contact: https://example.org/support, url: https://example.org}
version: 1.2.0
kind: sequencer                      # instrument, soundfont, sequencer, effect, player,
                                     # remixer or visualiser; a list is allowed
licence: Apache-2.0                  # SPDX id, or "LicenseRef-<x>" with a LICENSE file
affirmation: aff_01JC6ZK3W0R5        # the storefront affirmation record id (§21)
base: bollawax-cartridge-base:0.1
compute: {tier: 1, min_ram_mb: 384}  # tier 1 = Zero 2 W class, 2 = Pi 4 / CM4, 3 = Pi 5 / CM5
power: {profile: [self, vstack, below, boll], peak_mw: 3500}
audio: {usb: true, abus_mix: optional, latency_ms: 12}
bollard: {profiles: [full, usb-c]}   # the top faces it has been tested on
capabilities:
  want: [clock, midi_in, nowplaying]
  can: [midi_out, audio_usb, cart_params]
caps:                                # shown in the Boll's UI (§19.1)
  sequencer: {tracks: 8, ppq: 96, patterns: 64}
  instruments: []
  effects: []
  max_stems: 0
params:
  - {id: swing, type: float, min: 0.0, max: 0.6, default: 0.12}
  - {id: kit, type: enum, values: [boll-909, boll-808, dusty], default: boll-909}
storage: {data_mib: 64}
entry: ["python3", "/app/main.py"]
```

| Field | Meaning |
| :--- | :--- |
| `bolltridge` | the Bolltridge extension version the image targets |
| `name` | `developer/image`, unique on the storefront |
| `developer` | name, a contact and a URL |
| `version`, `licence` | the image version and its SPDX licence |
| `affirmation` | the id of the developer's recorded affirmation (§21) |
| `kind` | what the image is (see the comment above); one kind or a list |
| `base` | the published cartridge base image it is built on |
| `compute` | the compute tier and the minimum RAM it needs |
| `power` | the power profiles it supports, and its peak draw |
| `audio` | USB Audio (required if it makes sound), analog mix-through (`true`, `false` or `optional`) and its output latency |
| `bollard` | the Bollard profiles it has been tested on |
| `capabilities` | the capability flags it asks for (`want`) and uses (`can`) |
| `caps` | what the Boll's UI shows (§19.1) |
| `params` | its parameters: id, type, range or values, default, and an optional unit |
| `storage` | the size of its writable `/data` volume |
| `entry` | the command the container runs |

The planned automated review refuses an image whose `cartridge.yaml` does not parse, whose `base` is not a published base, whose `want` or `can` name unknown flags, or whose declared tier does not boot on that tier's reference board.

Three worked examples show the range:

| Image | Kind | Tier | What it does |
| :--- | :--- | :-: | :--- |
| TR-Boll | sequencer, instrument | 1 | an 8-track step sequencer with three bundled kits. It follows `clock`, switches pattern banks at each part boundary from `nowplaying.parts`, and can claim channel 10 so its kit replaces the Boll's drums |
| Orchestral Hall | soundfont | 2 | 1.4 GB of redistributable orchestral and choir banks with its own synthesizer, offering 48 programs. The user assigns, for example, strings to channel 2, and the cartridge takes that voice over |
| Grain Field | effect, remixer | 2 | requests the current song's stems (with the user's consent), granulates chosen stems into a texture that follows the song's key and beat, and can also take the Boll's live mix down over USB Audio and return a granular wash |

## 21. Distribution (planned)

None of this exists yet. The plan is that routines, dock plugins and cartridge images will be published through a storefront and registry at bollawax.org, with routines first, because they are small and need no hardware.

- **Packaging.** Routines, dock plugins and cartridge images are published as OCI artifacts. Labels on each artifact declare its kind, the API or extension version, the hooks it uses (for routines), its compute tier (for cartridges), its version and its licence. The label names will be published with the storefront.
- **Publishing.** A developer records an affirmation (name, contact, licence, and the right to distribute everything inside, including every bundled soundfont and audio file), gets a short-lived push token for their own repository, and pushes. For cartridges that is a `linux/arm64` image built on the published base.
- **Automated checks.** Routines are loaded with the static checks and run under the guard on three fixture songs (a single, a suite, and a paused-and-resumed song) on a private device, and are rejected if they fail, break a limit or touch the guard. Cartridge images get a vulnerability scan, size and label checks, a manifest check, a check that they are built on a published base, a check for setuid binaries, and a boot test on an emulated cartridge against a simulated Boll that replays a session (hello, three songs with clock, MIDI and stems, and a crash and restart). Tier-1 images are also booted on real Raspberry Pi Zero 2 W boards. Provisional size limits: 600 MiB for tier 1, 2 GiB for tiers 2 and 3.
- **Signing.** Images that pass are signed with cosign, and the signature bundle travels with the image.
- **Delivery.** A Boll fetches what its owner chose with a short-lived token, verifies the signature, and installs a routine itself or relays an image to a cartridge over bollardeck. No device holds a long-lived credential, and a cartridge never holds one at all.
- **Unsigned images** are never distributed. They run only on a Boll whose owner has turned on hacker mode.

## 22. Open items

Not yet validated on hardware. Nothing in Part A or Part C has run on a Boll.

- **Not built yet:** the Boll's bollardeck service (dock sessions, BLE, consent prompts), the BLE UUID allocation (the UUIDs in §3.2 are placeholders), the binary LED characteristic, `remix` in `render` mode, voice takeover in the Boll's player, the image-update relay, the cartridge proxy, the cartridge base image and its client libraries, and the storefront with its routine and cartridge pipelines.
- **Estimates:** every latency, rate and transfer time in this document.
- **Undefined grants:** the grant that covers `get` with `"what": "routine"`, and the grant that covers `audio` with `"dir": "abus"`.
- **The `hello` profile value** for an accessory with no physical connection (BLE only) is not defined.
- **MRPH5:** the stem role mapping is a proposal until the format is frozen, and so is the per-corpus `dock_stems` setting.
- **Routines:** the plan and part fields beyond those listed in §12.8 are not frozen, and there is no stable import path yet for creating note objects from scratch (copy a received note instead).
- **Cartridge voices in exported songs:** whether a cartridge voice used during playback should also be rendered into the exported song (a "render with cartridge" remix), or stay a playback-only layer as proposed.
- **Cartridge attestation:** there is no hardware attestation in this version. A counterfeit cartridge could replay a valid `hello`, but gains nothing: the Boll gives cartridges no files, tokens or credentials, and stems only with the user's consent.
- **Network for cartridge images:** images get no network in this version. If that changes, it will need a consent flag and a storefront policy.
- **Image sizes** for soundfont-heavy images (1–2 GiB) and how they are hosted.

## 23. Licence

Proposed, and not yet final:

- **This specification text:** CC BY 4.0.
- **Code**, including the example dock, the example routines and the bollardeck client libraries: Apache-2.0.
- Third-party packages in the cartridge base image keep their own licences, and the base image will publish their notices. Cartridge images carry whatever licence their developer declares.

These licences cover the documents and code, not the project's names and logos.
