# Apps region and containers

How a Kode Dot stores installed apps and kodeOS updates in flash: the A/B directory, the boot request, the mailbox, .kfw containers and the app manifest.

## What it is

An app on a Kode Dot is a **whole firmware**, not a plugin. kodeOS writes its `.bin` into the flash region `apps`, and a small bootloader override jumps to it in place. Any reset brings the board back to kodeOS, because the app never touches the partition that says which kodeOS to start.

The module `kdrv/apps` is everything that handles those images:

| Part | File | Job |
| --- | --- | --- |
| The apps region | `kdrv_apps.c` | The A/B directory, the allocator, image writes and checks, the boot request |
| The mailbox | `kdrv_apps.c` | `app_pending`: an image a computer drops with `esptool`, adopted at the next boot |
| kodeOS slots | `kdrv_slot.c` | `factory`, `ota_0`, `ota_1` and `otadata`: where kodeOS itself lives |
| Containers | `kdrv_kfw.c` | The `.kfw` format and a streaming decoder in front of any of these destinations |
| The manifest | `kapp_desc.c` | The display name and categories an app declares inside its image |
| Category overrides | `kapp_cat.c` | The board's own category table, kept in NVS |
| The app folder | `kapp_data.c` | The reader for an app's `app.json` |

Two Active Objects use it. **AO_AppMgr** owns the directory, the images, the mailbox and the write to the free kodeOS slot (`sdk/kaos/appmgr`). **AO_Boot** owns the boot request and `otadata` (`sdk/kaos/boot`). The design of the region and the boot model are on `internals/apps-region` and `internals/boot-model`.

## How it is connected

```mermaid
flowchart LR
  subgraph aos["Active Objects"]
    am["AO_AppMgr"]
    boot["AO_Boot"]
  end
  subgraph drv["kdrv/apps"]
    kfw["kdrv_kfw<br/>decoder"]
  end
  subgraph flash["Flash destinations"]
    apps["kdrv_apps<br/>apps region"]
    slot["kdrv_slot<br/>free OTA slot"]
    mb["kdrv_apps<br/>app_pending"]
  end
  am --> kfw
  kfw --> apps
  kfw --> slot
  kfw --> mb
  boot --> apps
  boot --> slot
  apps -- "kapps_fmt.h" --> bl["bootloader override"]
  class am ao
  class boot ao
  class apps store
  class slot store
  class mb store
```

*Who writes where*

`kapps_fmt.h` is the one header the driver and the bootloader share, word for word, so it includes nothing from ESP-IDF. It holds the magic numbers, the layout, the boot request and the rules both sides apply to them, as `static inline` functions. The bootloader and kodeOS are updated apart, so that layout never moves.

### Why the directory has two copies

**Flash only clears bits.** An erase sets every bit of a 4 KB sector to 1, and a write can only change a 1 to a 0. Two designs follow:

- The directory is kept twice, because a power cut in the middle of one sector write must leave a whole copy.
- The boot request moves from one state to the next with a single 4-byte write and no erase, because each new state only clears more bits.

**The directory has two copies, A and B.** `kdrv_apps_dir_commit()` never touches the copy believed valid. It writes the other one with `gen` one higher and a fresh CRC, waits for the write, and only then swaps its RAM copy. Inside a copy, the magic word is written last, so a cut leaves a sector that reads blank or corrupt and never one that looks whole. The commit refuses (`KODE_DENIED`) a directory that `kapps_dir_sane()` rejects, so the driver never seals a bad directory with a good CRC.

**Init sorts each copy into one of four kinds.** `kdrv_apps_init()` is idempotent: AO_Boot and AO_AppMgr both call it and whoever runs first pays the read. It also starts the category table first, so overrides are visible from the first manifest read.

| Kind | Meaning |
| --- | --- |
| ok | Magic, version and CRC hold, `count` fits the array of 64, and every live entry is an image the region could boot |
| blank | The magic reads as erased flash |
| corrupt | Another magic, a CRC that fails, or a copy that fails `kapps_dir_sane()` |
| foreign | The sector could not be read, or has the region's magic and another format version, as a different kodeOS would write |

If either copy is foreign, the driver uses neither and formats nothing. It logs `apps region directory unreadable or of another format`, returns `KODE_ERROR` and leaves the region shut: every call that needs the partition refuses. A later init looks again. Otherwise the ok copy with the highest `gen` wins (A wins a tie). When neither is ok, the region is formatted: an empty directory with `gen` 1 goes to A and B is erased so it cannot pass for newer.

### Placing and writing an image

**The allocator is log-structured and next-fit.** `kdrv_apps_plan()` touches no flash and keeps no state, so the host tests run it without a board.

1. Round the image size up to 64 KB. An image that cannot fit in an empty region is refused (`KODE_ERROR`).
2. Collect the live entries and sort them by offset.
3. Start the search right after the newest install (highest `seq`), rounded up to 64 KB.
4. Take the first gap that fits, looking from the search start to the end of the region, then from `KAPPS_DATA_OFF` up to the search start.
5. If nothing fits, evict the entry named by `replacing` first, then the oldest live entry (lowest `seq`), add its extent to the search and go again. The order of `evict[]` is the order apps are freed in.

The eviction is first in, first out by install order. kodeOS has no notion of use to rank by. The one exception is `replacing`, the entry of the app the image is a new version of (`KAPPS_NO_VICTIM` for a new app): its old copy goes before any other app, because the install replaces it anyway. When `evict[]` holds it, `replaced` names it, and the caller does not count it as lost.

The plan also predicts one more victim, `slot_victim`: when all 64 directory slots would still be live after the evictions, AO_AppMgr removes the oldest remaining entry after the write. With `replacing` live there is none, because the new version takes its old copy's slot. Without it the plan is a prediction: an image with the same name as an installed app still replaces that app in its own slot, and only the written image tells. Replacing by name is AO_AppMgr's job. The driver's directory calls are generic: read, plan, commit.

**A write session erases as it goes.** `kdrv_apps_write_begin(offset, size)` opens a session. `kdrv_apps_write()` erases a 64 KB block only when the write reaches it, so the GUI keeps running between AO_AppMgr's 32 KB chunks.

The bytes reach flash through **2 KB of internal RAM** (`KDRV_BOUNCE_BYTES`, borrowed per call from the heap). `esp_flash_write()` takes its fast path only from internal memory, and from PSRAM it programs 32 bytes at a time with the cache off.

Every image arrives in PSRAM. A 1.5 MB app from the card installs in about 8 s this way, against about 14 s straight from PSRAM, measured on a Kode Dot. If the heap has no 2 KB to lend, the write goes straight from the caller's buffer, slower and still correct. The mailbox and the kodeOS slot use the same helper (`kdrv_bounce.h`).

`kdrv_apps_write_end()` closes the session and runs `kdrv_apps_verify()` over exactly the bytes written, a plain `esp_image_verify()`. `kdrv_apps_write_abort()` only closes the session. The partial bytes stay in flash, unchecked and never entered in the directory.

### Launching an app

**The boot request says what the bootloader runs next.** `kdrv_apps_req_set()` accepts only the states `NEW` and `ALWAYS`. It checks `kapps_req_bounds_ok()` first, so the refusal happens while there is still a console to explain it, then erases the request sector and writes the 20-byte record.

```mermaid
sequenceDiagram
  autonumber
  box "kodeOS"
    participant AM as AO_AppMgr
    participant B0 as AO_Boot
  end
  participant R as apps region
  participant BL as bootloader
  AM->>B0: BOOT_APP_RUN_SIG offset size mode
  B0->>R: req_set: erase, write KRUN NEW or ALWAYS
  B0->>B0: reset the chip
  BL->>R: read request, check CRC and bounds
  BL->>R: NEW becomes TRIED, ALWAYS becomes ARMED
  BL->>BL: jump to the app image
  Note over B0,R: next kodeOS boot: req_tried() yes, then req_clear()
```

*One launch, from AO_AppMgr to the way home*

`NEW` means one boot. `ALWAYS` is the sticky request that only mailbox adoption writes, so an uploaded sketch survives the Serial Monitor and the debugger. The bootloader marks it `ARMED` on the first boot, and from then on keeps it only across the resets in the table under Specifications. Back in kodeOS, `kdrv_apps_req_tried()` is how AO_Boot knows it is "back from the user app". `kdrv_apps_req_armed()` asks the same about the sticky state.

### Adopting a mailbox image

**The mailbox is checked in two phases.** `app_pending` (6 MB at `0x1A00000`, subtype `0x41`) is non-executable, holds one image or one container at offset 0 and has no directory. A host tool writes the image with `esptool`, and a separate word at `0x5FF000` says what it is for (see Specifications).

1. At boot, `kdrv_apps_pending_present()` reads one byte. `0xE9` means a bare image, and `pending_walk_len()` walks its header chain with `esp_partition_read()` to find its length. This is a **length check only**: it catches a torn or garbage upload, not a wrong hash. A walk that does not hold together consumes the mailbox at once, so a broken upload is not re-checked on every boot.
2. Any other first byte may start a `.kfw` container, whose header states its own length. A container claiming more payload than the mailbox holds is discarded.
3. When an install must evict an app, `kdrv_apps_pending_verify()` runs the full `esp_image_verify()` on the mailbox in place, so a corrupt upload cannot cost anyone an installed app. A container is not verified here.
4. AO_AppMgr copies the image into the region, and `kdrv_apps_write_end()` runs the full check on the copy.

The walk does not trust the length a segment header states. It bounds the inputs of each addition, not its result, so a value near `UINT32_MAX` cannot wrap the offset around the partition.

The in-place check and the check on the copy need the 32-bit flash cache mapping (`CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH`), because both `app_pending` and the upper half of the region lie above 16 MB. Nothing boots on the strength of the length check alone.

`kdrv_apps_pending_present()` reports two lengths. `img_len` is what the mailbox holds, which is what a copy reads. `decoded_len` is what the image occupies once decoded, which is what an eviction plan is made for. They are equal for a bare image. A deflated container holds fewer bytes than it places.

The mailbox sink keeps a container **verbatim**, header and compression included, so reading it back checks it again. This is the one place where the decoder is not in the path, and `kdrv_apps_pending_write_end()` checks only the byte count. `kdrv_apps_pending_consume()` erases the first sector and the intent sector every time, so an intent flag never outlives its image.

### Decoding a container

**A container is decoded as a stream.** `kdrv_kfw.c` holds the format, a decoder and the contract of a **sink**, the destination of a decoded image:

| Sink call | Meaning |
| --- | --- |
| `begin(ctx, img_len)` | Open a session for `img_len` decompressed bytes, or 0 when the source cannot say. A sink that needs the length refuses here |
| `write(ctx, data, len)` | Append one chunk. May block for as long as the flash takes |
| `end(ctx)` | Close and verify |
| `abort(ctx)` | Abandon. Always safe, even before `begin()` |

Four sinks exist: `kdrv_slot_sink()` (with a size pre-check), `kdrv_apps_sink()` for the region, `kdrv_apps_pending_sink()` for the mailbox and `kfw_buf_sink()`, which fills a buffer the caller owns. The ESP32-C5 update uses the buffer sink, because a synchronous sink would block AO_Radio for a round trip per chunk.

The decoder is one static session with five states.

```mermaid
stateDiagram-v2
  direction LR
  [*] --> IDLE
  IDLE --> SNIFF: kfw_begin
  SNIFF --> RAW: first byte 0xE9
  SNIFF --> BODY: 72 header bytes accepted
  SNIFF --> DEAD: bad header, wrong target, wrong chip
  RAW --> DEAD: sink error
  BODY --> DEAD: overrun, bad deflate, sink error
  RAW --> IDLE: kfw_end or kfw_abort
  BODY --> IDLE: kfw_end or kfw_abort
  DEAD --> IDLE: kfw_end or kfw_abort
```

*The decoder's states*

- **SNIFF.** One byte settles the raw case, since a raw image has no length to wait for. A raw image names its chip at byte 12 of its header, and one built for another chip than the target needs is refused before anything is placed. The P4 is the chip for the OS and app targets, the C5 for the C5 target. Any other first byte accumulates until 72 bytes are in, then `kfw_hdr_parse()` runs. A container that declares a different target than the caller asked for is refused: the command decides where an image goes and the file only agrees or not.
- **Deflate.** For a deflated payload the decoder takes a decompressor and a 32 KB dictionary from PSRAM. The inflater is `tinfl_decompress` in the P4's ROM, so compression costs the image no flash. A failed allocation frees whatever half succeeded and returns `KODE_NO_RESOURCES`.
- **Digest.** A SHA-256 (PSA Crypto) runs over the **decoded** output. At `kfw_end()` the count must equal `image_len` and the digest must match, both checked before `sink->end()`, so a bad image is abandoned and never committed. A raw image has no digest, and its sink's own check is the only one.
- **Header callback.** The callback fires with the image length and the container's name before the sink opens, so a caller that must place the image first, like the region's allocator, can.
- **One session.** `kfw_begin()` while one is open returns `KODE_DENIED`. AO_AppMgr and AO_Radio share the decoder, and each ends its own session with `kfw_abort_owned(ctx)`.

### kodeOS slots and the app guard

**The kodeOS slots.** `kdrv_slot.c` is the only file that calls the `esp_ota_*` functions. "The slot" means whichever of `ota_0` and `ota_1` kodeOS is not running (from `factory` it is `ota_0`).

`kdrv_slot_begin()` opens the write with `OTA_WITH_SEQUENTIAL_WRITES`, so the erase is spread over the writes and the console does not freeze for seconds. `kdrv_slot_confirm_self()` must run at the top of every boot from an OTA slot: an unconfirmed image keeps `esp_ota_begin()` refusing, and no later update can start. The slot sink refuses an image bigger than the slot before the first byte, when the length is known.

**An app is built with a flash guard.** In a firmware built with `CONFIG_KODEOS_BOOT_ROLE_APP`, an app runs in place from a data partition, and ESP-IDF's write guard finds no running app partition and aborts every persistent write. `kdrv_slot.c` then wraps `esp_ota_get_running_partition()` to answer with a synthetic app partition covering the running image (subtype `APP_TEST`, so `kdrv_running_is_factory()` stays false) and `esp_ota_get_next_update_partition()` to answer `NULL`. The `--wrap` flags sit under the same role guard in the layer's `CMakeLists.txt`. kodeOS itself is built without them.

### Manifest, categories and app folder

**The manifest and categories.** An app may declare a display name and up to four categories in a `kapp_desc_t` placed at image offset `0x120`: IDF's own `esp_app_desc_t` starts at `0x20` and is 256 bytes, and a section placed right behind it lands at a fixed, computable address. `kdrv_apps_desc()` reads the first `KAPP_PREFIX_LEN` bytes of an image with **one** flash read and extracts both descriptors. A missing manifest is the common case: the launcher then shows the app as a loose row at the root, in no folder.

After every parse, `kapp_cat_resolve()` looks for a category the board set for the app, and an override beats the image, silence included. The board's table is one NVS blob in PSRAM, written only by AO_AppMgr.

**The app folder.** An app can carry an `app.json` and a 64 by 64 icon in a folder next to its image, under `.kode/appsdata/<project>` on either medium. `kapp_data_parse()` reads the file into fixed-size fields, allocates nothing and refuses the whole file on any fault, so a caller never draws half a folder. How an app gets its icon is on `internals/app-icon`.

## Specifications

### The region

| Thing | Value |
| --- | --- |
| Partition `apps` | Data, subtype `0x40` (`KAPPS_PART_SUBTYPE`), `0xC40000` to `0x1800000`, size `0xBC0000` (11.75 MB) |
| Partition `app_pending` | Data, subtype `0x41`, fixed at `0x1A00000`, 6 MB, non-executable |
| Byte order | Little-endian; every field is a `uint32_t`. The magic `0x5050414B` is stored as `4B 41 50 50`, the letters "KAPP" |
| Image alignment | 64 KB (`KAPPS_ALIGN`, `0x10000`): the MMU page, since an image maps for execution only from a page boundary |
| Directory capacity | 64 entries (`KAPPS_MAX_ENTRIES`) |
| Executing above 16 MB | The cache maps 32-bit addresses, so images run across the 16 MB mark |

Offsets inside the region, counted from its start:

| Offset | Contents |
| --- | --- |
| `0x0000` (`KAPPS_DIR_A_OFF`) | Directory copy A, one 4 KB sector |
| `0x1000` (`KAPPS_DIR_B_OFF`) | Directory copy B |
| `0x2000` (`KAPPS_REQ_OFF`) | Boot request, one 4 KB sector |
| `0x3000` | Reserved |
| `0x10000` (`KAPPS_DATA_OFF`) | First allocatable byte. Every image starts on a 64 KB boundary |

### The directory

`kapps_dir_t` is 1044 bytes in each copy. The CRC32 covers every byte before it. The valid copy with the highest `gen` wins.

| Offset | Size | Field | Meaning |
| --- | --- | --- | --- |
| `0x000` | 4 | `magic` | `KAPPS_DIR_MAGIC`, `0x5050414B` |
| `0x004` | 4 | `version` | `KAPPS_FMT_VERSION`, 1 |
| `0x008` | 4 | `gen` | Generation; grows with every commit |
| `0x00C` | 4 | `count` | Entries in use |
| `0x010` | 1024 | `e[64]` | 64 entries of 16 bytes |
| `0x410` | 4 | `crc` | CRC32 of every byte above |

Each entry (`kapps_entry_t`):

| Offset | Size | Field | Meaning |
| --- | --- | --- | --- |
| `0x0` | 4 | `offset` | From the region start; a multiple of `KAPPS_ALIGN` |
| `0x4` | 4 | `size` | Exact image length in bytes |
| `0x8` | 4 | `seq` | Install order, only grows: this is the FIFO |
| `0xC` | 4 | `flags` | Bit 0 is `KAPPS_ENTRY_VALID`; bits 1 to 31 are reserved |

### The boot request

`kapps_req_t` is 20 bytes (`KAPPS_REQ_LEN`), frozen by a `_Static_assert`. The CRC32 covers `magic` to `size` only. The field `state` sits **after** the CRC, so the bootloader can advance it with a 4-byte write and keep the record valid.

| Offset | Size | Field | Meaning |
| --- | --- | --- | --- |
| `0x00` | 4 | `magic` | `KAPPS_REQ_MAGIC`, `0x4E55524B` ("KRUN") |
| `0x04` | 4 | `offset` | Region-relative offset of the image |
| `0x08` | 4 | `size` | Exact image length |
| `0x0C` | 4 | `crc` | CRC32 of `magic` to `size` |
| `0x10` | 4 | `state` | One of the words below |

| State | Word | Written by |
| --- | --- | --- |
| `EMPTY` | `0xFFFFFFFF` | Erased flash |
| `NEW` | `0xAAAAAAAA` | kodeOS: boot this once |
| `ALWAYS` | `0x55555555` | Mailbox adoption only: boot this across USB resets |
| `ARMED` | `0x00005555` | The bootloader, on the first boot of an `ALWAYS` request |
| `TRIED` | `0x00000000` | The bootloader, when it retires a request |

Every step only clears bits: `NEW` to `TRIED`, `ALWAYS` to `ARMED` to `TRIED`. `kapps_req_bounds_ok(offset, size, region_size)` accepts an image only when it is at or past `KAPPS_DATA_OFF`, on a 64 KB boundary, not empty, not past the end of the region and fully inside it. The writer refuses a request that fails it, and the bootloader checks again, since flash is not safe only because a trusted writer wrote it once. The design is incompatible with flash encryption, which is not enabled.

An `ARMED` request survives these resets (`kapps_req_cause_keeps()`, a closed list, ROM numbering) while the mailbox is empty:

| Code | Cause |
| --- | --- |
| `0x03` | Core software reset: OpenOCD's first reset |
| `0x05` | Deep-sleep wake |
| `0x0C` | CPU software reset: `esp_restart()`, a panic's reboot |
| `0x16` | USB-JTAG: OpenOCD over USB |
| `0x17` | USB-UART: a host opened the port |
| `0x18` | CPU JTAG: OpenOCD's reset halt |

Power-on (which BOT+LEFT is), the watchdogs, a brownout, a full mailbox and any other cause retire it to `TRIED`, and kodeOS boots.

### The mailbox

| Offset | Contents |
| --- | --- |
| `0x000000` | The image or container, up to `KAPPS_PENDING_INTENT_OFF` bytes |
| `0x5FF000` (`KAPPS_PENDING_INTENT_OFF`) | The intent word, in the last 4 KB sector |

The word `KAPPS_OS_INTENT_MAGIC` (`0x41544F4B`, "KOTA") there means "adopt this as a kodeOS update, into the free OTA slot". Anything else, erased flash included, means "adopt this as an app", the default. A host tool writes the word separately, before the image. Nothing infers intent from the image's bytes. The constant is the contract with host tools that do not read the partition table, and a host test pins it to `partitions.csv`.

### The container

A `.kfw` file is a 72-byte header and a payload. Everything in the header describes the **decompressed** image: the region allocator needs its length before the first byte is written, and the digest has to vouch for the image, since a digest of the payload would pass a correct transfer of a corrupt file.

| Offset | Size | Field | Meaning |
| --- | --- | --- | --- |
| `0x00` | 4 | `magic` | `KFW_MAGIC`, `0x3157464B` ("KFW1") |
| `0x04` | 2 | `hdr_version` | 1 |
| `0x06` | 2 | `hdr_len` | 72 |
| `0x08` | 1 | `target` | 0 `KFW_TARGET_OS` (free kodeOS slot), 1 `KFW_TARGET_APP` (apps region), 2 `KFW_TARGET_C5` |
| `0x09` | 1 | `comp` | 0 `KFW_COMP_NONE`, 1 `KFW_COMP_DEFLATE` |
| `0x0A` | 2 | `flags` | Must be 0 |
| `0x0C` | 4 | `payload_len` | Bytes after the header |
| `0x10` | 4 | `image_len` | Bytes after decompression |
| `0x14` | 32 | `sha256` | Of the decompressed image |
| `0x34` | 4 | `crc` | CRC32 of the 52 bytes before it |
| `0x38` | 16 | `name` | NUL-terminated (`KFW_NAME_MAX`) |

`kfw_hdr_parse()` checks the magic (a mismatch is a quiet `KODE_ERROR`: not a container), the CRC, the version and length, the target and compression ranges, `flags == 0`, both lengths non-zero, `payload_len == image_len` when uncompressed, and a terminated name. It fills its output only on `KODE_OK`. `KAPPS_KFW_MAGIC` in `kapps_fmt.h` is a second copy of `KFW_MAGIC`, held equal by a `_Static_assert`, so the bootloader can tell a parked container from erased flash without the decoder. The host-side producer is `tools/mkkfw.py` in the kodeOS repository.

### The manifest

`kapp_desc_t` is 136 bytes at image offset `0x120` (`KAPP_DESC_OFF`), checked at compile time against `KAPP_IDF_DESC_OFF` plus `sizeof(esp_app_desc_t)`. Its offsets below count from the manifest's start.

| Offset | Size | Field | Meaning |
| --- | --- | --- | --- |
| 0 | 4 | `magic` | `KAPP_DESC_MAGIC`, `0x4450414B` ("KAPD") |
| 4 | 4 | `version` | `KAPP_DESC_VERSION`, 2: the struct's version, not the app's |
| 8 | 32 | `name` | Display name, terminator included (`KAPP_NAME_MAX`) |
| 40 | 64 | `category` | Comma-separated list (`KAPP_CATEGORY_MAX`) |
| 104 | 32 | `reserved` | Eight words, zero |

An app declares it once, at file scope: `KODE_APP_MANIFEST("Kode Runner", "game,demo");`. Static asserts refuse a name or list that is too long. The magic is the only thing that tells a manifest from whatever else the linker put at `0x120` in an app that declared none.

`kapp_desc_parse()` is pure and trusts nothing, since every byte may have come off a card. Bad magic or an unterminated string is `KODE_ERROR`, which means "unmanifested" and is never a failure to report. A version 1 manifest (88 bytes, `KAPP_DESC_V1_SIZE`, one 16-byte category) is read by its own bound and never by `sizeof(kapp_desc_t)`. Any other version is `KODE_ERROR`. Three more readers work on an image prefix: `kapp_project_name()`, `kapp_idf_desc()` (project, version, build date and time) and `kapp_image_chip()`, which reads the chip id at byte 12 of the image header and keeps a P4 image out of the C5.

| Constant | Value | Meaning |
| --- | --- | --- |
| `KAPP_CATEGORY_ITEM_MAX` | 16 | One category, terminator included |
| `KAPP_CATEGORIES_MAX` | 4 | Categories for each app |
| `KAPP_CATEGORY_MAX` | 64 | 4 times 15, plus 3 commas, plus the terminator |
| `KAPP_CAT_ROWS_MAX` | 64 | Apps that may carry an override |
| `KAPP_PREFIX_LEN` | `0x120` plus 136 | Image bytes a reader needs to hold |

The list has one form everywhere: lowercase words separated by commas. `kapp_cat_normalise()` lowercases, trims spaces, drops duplicates, caps the list at 4 and logs anything it drops. `kapp_cat_has()` matches a whole item, so `game` does not match `games`.

The override table is one NVS blob, namespace `kode`, key `app_cats`:

| Offset | Size | Field | Meaning |
| --- | --- | --- | --- |
| 0 | 4 | `magic` | `KAPP_CAT_BLOB_MAGIC`, `0x5441434B` ("KCAT") |
| 4 | 4 | `version` | 1: the blob layout |
| 8 | 4 | `count` | Rows |
| 12 | 96 each | rows | `project[32]` then `category[64]`; at most 64 rows, 6,156 bytes in all |

`kapp_cat_deserialise()` accepts a table only if all of it is valid. Magic, version, count and length must agree, and every `project[]` and `category[]` must carry a NUL inside its array. Each row's category is normalised on the way in. The rows live in PSRAM, taken once by `kapp_cat_init()`. A change is made in RAM first and persisted after, so a failed NVS write leaves the new table in RAM until the next boot, and the caller hears the failure.

### The app folder

`app.json` is one flat JSON object of string values, at most 1024 bytes (`KAPP_DATA_JSON_MAX`). Escapes are limited to `\"` and `\\`. No numbers, arrays or nesting.

| Key | Rule |
| --- | --- |
| `kode` | Required, and must be `"1"` |
| `name` | Display name, up to 31 characters |
| `category` | A list, normalised like a manifest's |
| `version` | Up to 15 characters |
| `description` | Up to 52 characters |
| `icon` | A relative path of up to 40 characters, with no leading `/`, no `\` and no `..` component |
| `color` | `"#rrggbb"` |

A key the reader does not know is ignored whatever its length, so a newer tool's file still works on an older board. `kapp_data_parse()` returns `KAPP_JSON_OK`, `_NONE` (no file), `_SIZE` (over 1024 bytes), `_SYNTAX` (outside the subset or a field over its limit) or `_REVISION` (`kode` missing or not `"1"`), and zeroes its output on anything but OK. The icon file `icon.k64` is 12288 bytes (`KAPP_ICON_BYTES`): 64 by 64 pixels of RGB565 (8192 bytes) followed by 64 by 64 of alpha (4096 bytes).

### The calls

| Call | What it does |
| --- | --- |
| `kdrv_apps_init()`, `_present()`, `_region_size()`, `_address()` | Open the region and describe it |
| `kdrv_apps_dir()` | The working copy of the directory in RAM. Never `NULL` |
| `kdrv_apps_dir_commit(d)` | Write the other copy, then swap. `KODE_DENIED` for an insane directory |
| `kdrv_apps_plan(d, region_size, img_size, replacing, &plan)` | Where the image goes and who is evicted, the app being replaced first. Valid only for the directory it ran on, so apply the evictions and commit before writing |
| `kdrv_apps_write_begin(off, size)` | `KODE_DENIED` if the region is absent or a session is open. `KODE_ERROR` for an offset below `KAPPS_DATA_OFF`, a misaligned one or an extent past the partition |
| `kdrv_apps_write()`, `_write_end()`, `_write_abort()`, `_sink()` | Stream the image in, verify it, or walk away. The sink needs `kdrv_apps_sink_ctx_t{offset}` and refuses `img_len == 0` |
| `kdrv_apps_read(off, buf, len)` | Read from the data area. No session. `KODE_ERROR` below `KAPPS_DATA_OFF` or past the partition |
| `kdrv_apps_desc(entry, &desc, &manifest)` | One flash read, both descriptors, then the override |
| `kdrv_apps_verify(off, size, &len)` | `esp_image_verify()` at the region's absolute address; optionally the true image length |
| `kdrv_apps_erase_header(off)` | Erase the first 4 KB of an extent: the cheap uninstall |
| `kdrv_apps_req_set()`, `_req_tried()`, `_req_armed()`, `_req_present()`, `_req_clear()` | The boot request |
| `kdrv_apps_pending_present()`, `_verify()`, `_read()`, `_os_intent()`, `_consume()` | The mailbox |
| `kdrv_apps_pending_write_begin()`, `_write()`, `_write_end()`, `_write_abort()`, `_sink()` | Write it from kodeOS. Capped at `KAPPS_PENDING_INTENT_OFF` bytes |
| `kfw_begin()`, `kfw_feed()`, `kfw_end()` | One decode session. `kfw_begin` takes the sink, its ctx, the wanted target, the source length, a header callback |
| `kfw_abort()`, `kfw_abort_owned(ctx)`, `kfw_busy()`, `kfw_produced()` | Abandon, ask, count the image bytes handed to the sink (not the bytes fed in) |
| `kfw_hdr_parse()`, `kfw_sha256()`, `kfw_crc32()`, `kfw_selftest()` | Header check, digest, CRC, and the self-test behind `test component=kfw` |
| `kdrv_slot_present()`, `_size()`, `_address()`, `_desc()`, `_has_image()`, `_info(id, &info)` | The free slot, and any of the three kodeOS partitions by id |
| `kdrv_slot_begin()`, `_write()`, `_end()`, `_abort()`, `_sink()`, `_erase()` | Write the free slot. `erase` clears its first 64 KB |
| `kdrv_slot_confirm_self()` | Confirm the running image if it is still `PENDING_VERIFY` |
| `kdrv_otadata_blank()`, `_clear()`, `_select_slot()`, `kdrv_slot_state()` | Read, reset and point `otadata`. `select_slot` verifies the image first |
| `kdrv_slot_coredump()`, `kdrv_slot_coredump_reason()` | Whether a core dump waits in flash and whose it is |
| `kdrv_running_info()`, `_desc()`, `_is_factory()`, `kdrv_reset_reason()` | The running partition and the chip's reset reason |

The partitions behind the slot driver:

| Partition | Offset | Size |
| --- | --- | --- |
| `otadata` | `0x1B000` | `0x2000` |
| `factory` | `0x20000` | `0x400000` (4 MB), the rescue kodeOS, reachable by cable only |
| `ota_0` | `0x420000` | `0x400000` (4 MB) |
| `ota_1` | `0x820000` | `0x400000` (4 MB) |
| `coredump` | `0xC20000` | `0x20000` |

### The files

| File | What it does |
| --- | --- |
| `include/kapps_fmt.h` | The on-flash format and the rules shared with the bootloader. No ESP-IDF |
| `include/kdrv_apps.h`, `src/kdrv_apps.c` | The region, the allocator, the write session, the boot request, the mailbox |
| `include/kdrv_slot.h`, `src/kdrv_slot.c` | kodeOS slots, `otadata`, self-confirm, the core dump query, the app-role flash guard |
| `include/kdrv_kfw.h`, `src/kdrv_kfw.c` | The container header, the streaming decoder, the sinks contract, the buffer sink, the self-test |
| `include/kapp_desc.h`, `src/kapp_desc.c` | The manifest, the image-prefix readers, `KODE_APP_MANIFEST` |
| `include/kapp_cat.h`, `src/kapp_cat.c` | Category lists and the NVS override table |
| `include/kapp_data.h`, `src/kapp_data.c` | The `app.json` reader |
| `src/kdrv_bounce.h` | Flash writes through 2 KB of internal RAM. Private |
| `src/kdrv_desc_fill.h` | IDF's app descriptor into `kdrv_slot_desc_t`, each string read with its array's width. Private |

## Gotchas

> **DANGER**
>
> **The CRC of a boot request must not cover `state`.** The bootloader rewrites `state` in place with a 4-byte write. If the CRC covered it, that write would invalidate the record, and the next boot could not tell a retired request from garbage.

> **WARNING**
>
> **A raw `.bin` of unknown length cannot target the region.** The allocator needs the image length before the first byte, and a bare image has one only if the transport supplies it. A raw `.bin` over HTTP is this case. Send such an app in a `.kfw` container.

> **WARNING**
>
> **Skip the self-confirm and updates stop.** `kdrv_slot_confirm_self()` belongs at the top of every boot from an OTA slot. An unconfirmed image leaves `esp_ota_begin()` refusing every later session.

> **WARNING**
>
> **A leftover `ARMED` request relaunches the app.** A flash of kodeOS does not write the apps region, so a sticky request outlives it and the board keeps booting the app. BOT+LEFT (a power-on reset) retires it.

> **NOTE**
>
> **`KODE_NO_RESOURCES` from `kfw_feed()` means drain and retry.** With the buffer sink, empty the buffer and offer the same chunk again. Treat it as fatal and the image is lost. Ignore it and the digest refuses the image for missing bytes.

> **NOTE**
>
> **The verdict on a digest comes at `kfw_end()`.** A sink that commits during `write()` cannot be un-written, so all three flash sinks commit in `end()`.

> **NOTE**
>
> **`slot_victim` is not in `evict[]` and is not certain.** Look at both: `evict[]` lists the apps that go for sure, `slot_victim` the one that goes unless a namesake frees its own slot. Pass `replacing` whenever the image's name is known: a namesake the plan was not told about costs the oldest app its place.

> **NOTE**
>
> **Display names are not identities.** Two apps may declare the same display name. The launcher, the directory, the overrides and the stem match against `.bin` files all use the project name.

> **NOTE**
>
> **Removing `retain` from `KODE_APP_MANIFEST` deletes every manifest.** The build still succeeds, and the linker collects the unreferenced section. Without the attribute `.flash.appdesc` is exactly `0x100` bytes and the manifest is absent. The macro also leaves `kode_app_desc` with external linkage on purpose, so a second use in one firmware is a duplicate-symbol error instead of two structs fighting for the same bytes.

> **NOTE**
>
> **Take the category locks in order.** Writers hold `s_persist_lock` first and `s_lock` only inside it. The row lock is released before any flash access, so a reader never waits for an erase.

> **NOTE**
>
> **`--wrap` cannot reach `esp_ota_begin()` inside an app.** The flag redirects only undefined references, and `esp_ota_begin()` calls the lookup from inside `esp_ota_ops.o`. An app has no OTA slot to write anyway. To keep state it uses `kode_nvs_*` or files.

## Example code

An app that declares its name and category, which is the part of this layer an app author writes. Install it, and the launcher shows "Hello" in the folder `examples`.

```c
/*
 * manifest.c - 2026-10-28 - from Kode with love
 *
 * Declares a display name and a category in the app's image, at offset
 * 0x120, then logs a line. After the install the launcher lists the app as
 * "Hello" in the folder "examples", and the log shows: hello from the apps
 * region.
 */
#include "kode.h"

#define TAG  "hello"

/* What the launcher shows, and the category it files this app under. */
KODE_APP_MANIFEST("Hello", "example");

/* Bring the system up, say hello, then idle so the console stays up. */
void app_main(void)
{
    kode_init();
    KLOG_N(TAG, "hello from the apps region");

    while (1) {
        kode_delay_ms(1000);
    }
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`components/kdrv/apps`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/apps): the region, slot, container, manifest, category and app-folder code.
- [`kapps_fmt.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/apps/include/kapps_fmt.h): the on-flash format, shared with the bootloader.
- [`bootloader_start.c`](https://github.com/kodediy/KodeOS/blob/main/bootloader_components/main/bootloader_start.c): the other reader of `kapps_fmt.h`, in the kodeOS repository.
