# Design rules

The nine rules every line of Kode Dot firmware follows, what checks each one, and the three places where "everything is an event" gives way.

## What it is

The SDK follows nine rules, and each one has a check. The check is a script, a test at boot, an error at build time, or a way of writing the code that leaves no room for the mistake. A change that breaks a rule is wrong until someone shows the rule does not apply.

Read this page before you change SDK code.

The rules speak of Active Objects, owners, events, posts and workers. If any
of those words is new to you, read "The ideas behind it" in `sdk/overview`
first: it explains each one in plain words.

If you write an app, the include checks (rule 2) do not read your files, because the script scans the `components/` folder of the SDK. Your Active Objects still follow rules 1, 3, 5 and 7.

## How it is connected

```mermaid
flowchart LR
  dev["You write code"]
  subgraph pr["Before every merge"]
    script["tools/check_layers.sh"]
    build["Compiler<br/>#error, _Static_assert"]
    gates["check_ram.sh<br/>check_misra.sh"]
  end
  subgraph run["At boot"]
    owner["Every Active Object<br/>kaos_claim()"]
    table["kbsp_owners.c"]
  end
  dev --> script
  dev --> build
  dev --> gates
  owner --> table
  script -- "rule 2: includes" --> ok["exit 0"]
  build -- "rule 8: #error" --> ok
  gates -- "rule 8, style" --> ok
  table -- "rule 1: two owners<br/>stop the boot" --> ok
  class dev app
  class owner ao
  class table store
```

*Where each rule is checked*

Nothing can check rule 1 from outside, because a script does not run the system. Every Active Object calls `kaos_claim()` for its devices in its initial transition. A second claim on the same resource prints which two owners met and stops the boot with `Q_ERROR()`.

The other rules are held by the compiler or by a script that runs before every merge (`internals/test-pipeline`). `tools/check_layers.sh` reads `.c`, `.h`, `.cpp` and `.hpp` files, so C++ code follows the same rules as C.

## Specifications

### The rules

The nine rules, with the check or the habit that holds each one.

| # | Rule | How it is held |
| --- | --- | --- |
| 1 | **One owner per device.** An Active Object claims its devices at startup. A bus is shared, a device is owned whole. Everyone else asks the owner by event. One gate breaks it, below | `kbsp_claim()` is an atomic compare-and-swap on one slot per resource. Two claimants stop the boot, naming both (`sdk/kbsp`) |
| 2 | **Each layer includes only the headers its place allows.** ESP-IDF stops at kdrv, QP/C stops at kqp, pins and addresses live in kbsp | `tools/check_layers.sh`, 13 checks, below |
| 3 | **Everything is an event, with three exceptions.** Requests wait in the queue of the owner | The three exceptions are listed below. A fourth needs the same written justification. The boot gate is a documented break of rules 1 and 3, below, because no queue exists when it runs |
| 4 | **Public headers of khal and kdrv return `kode_status_t`**, never `esp_err_t`. Some calls answer with a `bool` or a pointer. None leaks an ESP-IDF type | Layer check 1 keeps ESP-IDF headers out of everything above kdrv. No script looks for `esp_err_t` itself: review does, and no public header of khal or kdrv names it today |
| 5 | **Long work is cut into steps, runs at low priority, or both.** One step of an Active Object stays short, because its queue has a fixed size and QP/C stops the system when an event does not fit | AO_AppMgr writes an image in 32 KB chunks and waits 10 ms between them. kscreen arms its render tick as a one-shot after each pass, never as a periodic timer |
| 6 | **The system says why before it stops.** AO_Power logs before the write that switches the board off, and AO_LowPower logs the state it enters and why before it stops the tick. A destructive action asks, then confirms | AO_Power logs `ship mode: opening the BATFET` before the write. AO_LowPower logs `entering OFF (<why>)` before deep sleep. The Turn off card of kodeOS asks first |
| 7 | **An event carries a copy, not a pointer into a table of the sender.** A list travels as one event per entry and an end marker | Three documented exceptions for data too large to copy: `FlushEvt.pixels`, `StorageWriteEvt.data` and `DisplayGlyphsEvt.strip`, the digits of the clock that AO_Display draws while the board is asleep. The comment of each says what keeps the memory alive |
| 8 | **Internal RAM is taken by name.** The ESP32-P4 has 640 KB of it against 32 MB of PSRAM. A buffer is `khal_alloc_psram()`, never a `static`, unless it must be internal | `tools/check_ram.sh` fails a data symbol of 2048 bytes or more in internal RAM that has no line in the project's budget file. `#error` lines in `khal_system_esp.c`, `kdrv_radio.c`, `kdrv_ble.c` and `kdrv_net.c` stop a build that lacks the board's memory options (`sdk/kbsp`) |
| 9 | **A worker runs one job and decides nothing.** Waiting work leaves its Active Object for that Active Object's own worker task, which posts the result back as one `KqpWorkerDoneEvt` | `kqp_worker_submit()` is the only way in. The state machine, timeouts, retries, order of jobs and the device stay in the Active Object |

### The layer checks

`tools/check_layers.sh` ends with exit code 0 when no check failed. Its comments number the checks 1, 2, 3, 4, 5, 5b, 5c, 6, 7, 8, 9, 10 and 11.

- Lines that look like prose (a comment starting with `*` or `//`) are skipped.
- The vendored `qpc` and `rfal` folders are skipped.

When you add an `#include`, look up its row.

| Check | What it checks | Why |
| --- | --- | --- |
| 1 | `esp_*`, `driver/`, `freertos/`, `soc/` and `hal/` only in kbsp, khal, kdrv and klog | Above kdrv the code is portable C. `soc/` and `hal/` reach registers and do not start with `esp_` |
| 2 | `qpc.h` only in kqp | Everyone else gets QP/C through `kqp.h`, which `kode_ao.h` passes on to an app |
| 3 | `KBSP_PIN_*`, `KBSP_I2C_ADDR_*` and `KBSP_IOEXP_*` defined only in kbsp | A pin or an address has one definition |
| 4 | `<dirent.h>` and `<sys/stat.h>` only in `kdrv_storage.c` | AO_Storage owns the media. A second file that opens files is a second reader of the card |
| 5 | `driver/rmt_*` nowhere | The infrared block is wired to the ESP32-C5, so its RMT runs in the C5 firmware. `kdrv_ir.c` asks the C5 over the link |
| 5b | `esp_adc/*` only in `khal_exp_esp.c` and `kdrv_rfid_lf.c` | The expansion header has one ADC table. The RFID driver reads its own fixed pin |
| 5c | `driver/ledc.h` only in `khal_pwm_esp.c` | The LEDC timers are shared, so a second file could retune another user's timer |
| 6 | esp-hosted, `esp_wifi` and the four NimBLE include roots only in `kdrv_radio.c`, `kdrv_wifi.c`, `kdrv_ble.c` and `kdrv_c5.c` | These are one RPC channel to one device, the C5, and AO_Radio owns it |
| 7 | `lwip/`, `ping/`, `esp_http_client`, `esp_tls`, `esp_crt_bundle`, `mqtt_client` and the SNTP headers only in `kdrv_net.c` | Check 1 misses them, because their names do not start with `esp_` |
| 8 | `driver/i2s_*`, `esp_codec_dev`, `audio_codec_*` and `es8311_*` only in `kdrv_es8311.c` | The I2S controller is bound to five pins that already have an owner |
| 9 | `lv_*` calls only in kui and kscreen | LVGL has no lock, because one task drives it |
| 10 | `miniz.h` only in `khal_zlib_esp.c` and `kdrv_kfw.c` | The ROM inflater. One seam for whole streams, one more that feeds a chunk at a time |
| 11 | `cJSON.h` only in `kui_pack.c` | cJSON recurses as deep as the text nests, on the caller's stack. `kui_pack.c` bounds the depth first |

kodeOS adds two checks of its own for the code above the SDK:

- `lv_*` only in `components/kgui`.
- `cJSON.h` nowhere.

Its `tools/check_layers.sh` runs the SDK script after them, and prints `skipped` for the SDK part when the submodule is missing.

### The three exceptions to rule 3

Each row is a place where a queue cannot serve, and what the exception requires.

| Exception | Why a queue cannot serve | What it needs |
| --- | --- | --- |
| The end of a screen flush | The DSI interrupt that ends a flush can fire with the flash cache off (`CONFIG_LCD_DSI_ISR_CACHE_SAFE`), so it signals a semaphore instead of posting | `khal_sem_signal()` and everything the interrupt touches live in IRAM |
| The rescue path of the log | Inside an interrupt and inside `Q_onError()` stdio takes a recursive lock, and ESP-IDF answers that with a bare `abort()` | Print with `esp_rom_printf` through `khal_panic_print()` or `kode_log_emergency()`, never `printf` or `KLOG_x` (`sdk/klog`) |
| The panel reset at a restart | `esp_restart()` runs the shutdown handler in the task that asked for the restart, which can be esp-hosted's SDIO task. No queue is served in time | `kdrv_display` registers one handler that drives the reset line low and touches nothing else |

### The gate at the top of `kode_init()`

With `CONFIG_KODE_LOWPOWER`, `kode_init()` calls `kode_boot_gate()` right after the log starts, before any Active Object exists. A boot that wakes from OFF must go on only when BOT is down, and any of the buttons on the expander raises the line that wakes the board from deep sleep. Only the expander's input register says which button it was. So the gate calls `kdrv_tca6408a_init()` and `kdrv_tca6408a_get()` itself: it breaks rule 1, because AO_IoExp owns that chip, and rule 3, because it reads the chip with no event. The reason is that no queue, no event pool and no AO_IoExp exist yet. If BOT is not down, the board goes back to deep sleep. The break ends when the function returns, and from then on AO_IoExp is the only code that touches the expander (`sdk/kaos/lowpower`).

A GPIO interrupt is not a fourth exception. Its callback posts a static event with `kqp_post_from_isr()` and nothing more.

A few calls also reach a driver from the task of the caller, with no event, because the owner has handed the thing over or the thing has no owner:

- the network one-shots (`kode_net_ping()`, `kode_http_get()`)
- the card on loan after `kode_sd_lend()`
- the audio stream after `kode_audio_stream_open()`
- the display flush wait of the screens

### The style rules

The four rules about how the code is written.

| Rule | How it is held |
| --- | --- |
| All C follows MISRA C | `tools/check_misra.sh` runs cppcheck over the compile database. `tools/misra_deviations.txt` lists the rules the code base deviates from, each with its reason. The project's baseline file counts the other findings per file, and a file may not exceed its count |
| Every public name has a Doxygen comment with `@brief`, `@param` and `@return` | Written in the header. The contract lives in the `.h` |
| A number that means something is a named `#define` with a one-line comment | Review. Layouts that two programs share carry sizes and offsets as names and a `_Static_assert` |
| The simplest solution wins. Two mechanisms for one job means one is a bug | Review |

## Gotchas

> **WARNING**
>
> **A passing `check_layers.sh` does not read `#include <esp_log.h>`.** Checks 1, 2, 5, 5b, 5c, 6, 7 and 8 match the quoted form `#include "…"` only, so an angle-bracket include of the same header passes. Write quoted includes for SDK headers.

> **WARNING**
>
> **A worker whose stack is in PSRAM must not touch the internal flash.** NVS, the internal FAT, `esp_partition` and the NimBLE bond store switch the flash cache off, and a task with its stack in PSRAM cannot run through that.
>
> `khal_nvs_*` refuses such a call with `KODE_DENIED` and one error line. The crash for the other paths is deterministic but shows only when a flash write coincides with the job, so a bench run can miss it. Put a job that touches the flash on a worker created with `psram_stack` false.

> **NOTE**
>
> **Rule 9 lets the worker post one event.** The hand-back of a finished job, `KqpWorkerDoneEvt`, goes to the worker's own Active Object. A worker never posts to another Active Object and never calls another worker. A blocking facade request is allowed, because it asks the owner, who decides.

> **NOTE**
>
> **The 10 ms gap in an image write is deliberate.** AO_AppMgr outranks the GUI, so without the gap between chunks the interface would crawl for the whole write. A 5 MB image takes about 160 chunks and the gaps add about 1.6 s.

## Example code

Run the checks before you call a change done. Each step ends with a result you can read.

1. From the root of the SDK, run `tools/check_layers.sh`. Every line starts with `ok`, and the last line reads `All layer rules hold.`
2. Run `tools/host_tests.sh`. If you changed a header, delete the `tests/host*/build` folders first, because stale mocks fake compile errors.
3. Build your firmware, then run `tools/check_ram.sh build/<project>.elf <budget file>`. It prints the internal RAM used against the budget and says `all listed` for the large statics.
4. Run `tools/check_misra.sh build/compile_commands.json <baseline file> components`, from the folder the baseline's paths are relative to. A file with more findings than its baseline count fails.

## In the SDK

- [`tools/check_layers.sh`](https://github.com/kodediy/kode-sdk/blob/main/tools/check_layers.sh): the thirteen include checks, each with the reason in its comment.
- [`components/kbsp/owners`](https://github.com/kodediy/kode-sdk/tree/main/components/kbsp/owners): the ownership table that holds rule 1.
- [`tools/check_ram.sh`](https://github.com/kodediy/kode-sdk/blob/main/tools/check_ram.sh): the internal RAM gate for rule 8.
