# Composability — the building-block contract **Status:** Design record. The principles (one-way DAG, the stud vocabulary, the `TK_CONFIG_*` selectors) are **settled and in use** throughout the root tree; the *machine enforcement* (the minimal-build matrix, §6) is **designed but not yet migrated** — the tooling lives at `_legacy/tools/` and the sovereign/feature probe examples it drives do not exist at root (§7). Known glue is catalogued honestly (§7–8). **Scope:** the rules that keep k2's layers (HAL · kernel · application) and the components *within* each layer independently usable — "bricks connected by studs, not fused by glue." **Companions:** [kernel-tiers.md](kernel-tiers.md) (the header/symbol split), [board-contract.md](board-contract.md) (the board↔kernel boundary), [hal-architecture.md](hal-architecture.md) (HAL internals), ADR [0002](../decisions/0002-multi-instance-kernel.md) (§7 tracks the one live leak). --- ## 1. Thesis k2 is a set of **bricks**: independently selectable components you assemble into exactly the system you need — as much HAL, as much kernel, as much application as the job calls for, and no more. Bricks are not *independent* (a blocking mutex genuinely needs a scheduler); they are *decoupled* — they connect only through narrow, declared, one-way interfaces (**studs**), never through hidden, bidirectional fusion (**glue**). In an embedded, safety-minded system this is the opposite of a luxury: - **Pay only for what you use.** Flash/RAM budgets make à-la-carte composition a requirement. A brick you didn't select costs zero bytes. - **Composability is a safety asset.** A small, named set of bricks with freedom-from-interference is *easier* to certify than a monolith: smaller TCB, explicit interfaces, and an argument that an unused brick cannot affect a used one — because it isn't linked. - **Confidence is machine-checked, not hoped.** "This brick is decoupled" is provable: it links alone (§6). --- ## 2. The layer model and the one-way dependency rule Three layers, and a single invariant that governs all coupling: > **Dependencies flow one way only.** `hardware ← HAL ← kernel`, `HAL ← application`, > `kernel ← application` (optional). **No brick names a brick above it.** The graph is > a DAG. ``` application (sovereign, privileged, or confined — see below) / \ (optional) \ v v kernel ----> HAL ----> hardware ^ | (kernel and application are PEERS on the HAL) ``` Consequences that follow directly: - **The HAL is a capability layer, never a policy layer.** It abstracts hardware so the code above is portable; it has no power to restrict. "Running on the HAL" can never mean "limited." - **The kernel is the only layer that can restrict**, and only via its hardware-management machinery (context switch, MPU, the unprivileged-thread confinement). *Restriction is a kernel service, not a HAL property.* - **An application is a peer of the kernel on the HAL.** Both sit directly on it; the kernel is a component that *also* offers management. Hence three application modes: | mode | how it runs | hardware access | gets kernel services | |---|---|---|---| | **sovereign** (bare) | no kernel below it | full — identical to the kernel's | no | | **privileged** | under the kernel | full; drives hardware *in parallel* with the kernel | yes | | **confined** (`TK_CONFIG_UNPRIV_THREADS`) | under the kernel | mediated via the syscall trap; kernel RAM/peripherals locked | yes | "HAL-only" would be a misleading name for the first mode: it is the *maximal-freedom* mode. The "only" describes what is *below* the app (nothing but the HAL), not what it may *do* (everything). Prefer **sovereign / bare**. It gives up services, not rights. The confined mode's syscall/handle stud is specified in [kernel-tiers.md](kernel-tiers.md) (Tier A `[rt]` ops + the confinement seam). The corollary the whole document turns on: **the HAL must never depend on the kernel** for *behaviour*. The HAL knows nothing of the kernel's code; a HAL file that names a kernel **function or object** is a dependency inversion — glue — and §7/§8 show the places the root tree currently does this. **One narrow, deliberate exception (ADR 0002 review 0001, D1): shared pure-TYPE vocabulary.** `tk_status_t` (like `tk_stack_cell_t`) is a compile-time TYPE both layers agree on. The kernel's *public* API now returns `tk_status_t`, so an app that includes `tk_kernel.h` and a HAL header must see ONE definition or the enum constants collide — and the kernel may depend only on the arch layer, so the shared type cannot live in the HAL. It therefore lives in `kernel/tk_status.h` (a sibling of `kernel/tk_types.h`) and the HAL **borrows** it via `#include`. This is a **pure type borrow — zero code, zero link symbols**: a sovereign HAL build still references **no** kernel symbol (I2 holds; types don't link), it only needs `kernel/` on its include path. The forbidden thing stays forbidden — a HAL brick naming a kernel *function/object*. Graph: `arch <- kernel <- hal` (types only). --- ## 3. The brick map (what the build actually sees) The build's source groups *are* the brick boundaries. In the reshaped root ([`make/tk.mk`](../../make/tk.mk) + `make/board/.mk`) there are three: | group | defined in | contents | |---|---|---| | `TK_KERNEL_SRCS` | `make/tk.mk` | `kernel/tk_kernel.c` · `tk_sync.c` · `tk_trace_ring.c` — constant across every board/arch | | `TK_ARCH_SRCS` | `make/arch/.mk` | the CPU port (`arch//kernel_arch.c`, switch `.S`, …) | | `TK_BOARD_SRCS` | `make/board/.mk` | the board's HAL-port `.c` **and** `hal/tk_console.c` | > **Note — there is no `TK_HAL_SRCS` at root.** The portable HAL is *header-only* > (`hal/*.h`) plus the one shared `hal/tk_console.c`; every concrete peripheral driver > is a per-board `.c` swept into `TK_BOARD_SRCS`. So the HAL↔kernel seam is a property > of *symbols*, not of a separate source group — which is exactly why the I2 pull-test > (§6) needs a dedicated sovereign example rather than "link the HAL group alone." **HAL bricks** (`hal/` contracts + a board's HAL-port in `boards//`): - Peripheral / mechanism bricks — each its own header, its own `tk_*_init()`: `tk_uart` · `tk_dio`/`tk_port` · `tk_dio_irq` (edge→IRQ) · `tk_mcu`. - The portable text console `tk_console` (`tk_print`; one `.c` serves every board). - Leaf brick — the uniform return contract `tk_status_t`. It lives in `kernel/tk_status.h` (the shared type-vocabulary home, beside `tk_types.h`) so the kernel's public API can return it without a kernel→HAL edge; the HAL borrows it as a pure-type include (§2's exception). Depended on by everything, depends on nothing. *(The wider `_legacy` HAL — `tk_spi`/`tk_i2c`/`tk_adc`/`tk_pwm`/`tk_gpt`/crypto/`tk_wdg` + the `tk_error`/DET module — is **not** in the minimal root. It is `_legacy`-only; see [hal-architecture.md](hal-architecture.md) for what the minimal HAL keeps and drops.)* **Kernel bricks** (`kernel/`), in dependency order — each studs onto the one before: ``` scheduler ← sync (mutex/sem/cond) ← higher services ← system features ``` Scheduler-without-sync is valid; sync-without-features is valid; **never the reverse.** Feature bricks (`STACK_CHECK`, `MPU_GUARD`, `UNPRIV`, `ISOLATION`, `AFFINITY`, `EDF`, `PROFILING`, `INTROSPECT`, `TRACE`, `SYSVIEW`) each gate on a `TK_CONFIG_*` flag (§4c) and compile to nothing when off. The header/symbol placement of the kernel bricks is [kernel-tiers.md](kernel-tiers.md); this doc owns only the linkage/selection law. **Arch bricks** (`arch//`): the kernel's port to one CPU, reached *only* through the `arch_*` contract (§4f). The board↔arch division of that contract (who owns the tick, the vectors, the MPU map) is [board-contract.md](board-contract.md). **A board = a HAL-port (always) + kernel-facing glue (only on kernel boards).** That split is the heart of §7: today the two are not cleanly separated in the source grouping for the Cortex-M kernel boards. --- ## 4. The stud catalog — the approved connectors The *only* sanctioned ways one brick connects to another. Every one shares a property: **the lower/used brick never names the higher/using brick.** **(a) Callback / hook registration (inversion-of-control).** The used brick exposes "register a handler"; the using brick plugs in. The canonical stud — it turns would-be glue around. > `tk_dio_irq_enable_channel(cb, ctx)` (edge→callback) · `tk_kernel_set_idle_hook`. **(b) Weak default + strong override (link-time stud).** A brick ships a weak default; a higher brick optionally overrides it. Absent the override, the default stands — so the brick links alone. > `tk_console_lock`/`tk_console_unlock` (weak no-ops single-core; an SMP board overrides > with a hardware spinlock) · the `ALIAS_DEFAULT` vector handlers in each > `boards//startup_*.c` · the weak `arch_timer_init` a tick-owning board overrides. **(c) Compile-time selectors (`TK_CONFIG_*`).** The à-la-carte switch: which bricks compile in. Each must be *independently* toggleable (the §6/I1 invariant). > `TK_CONFIG_ENABLE_{PROFILING,EDF,AFFINITY,INTROSPECT,STACK_CHECK,MPU_GUARD,MPU_ISOLATION,TRACE,SYSVIEW}` > · `TK_CONFIG_UNPRIV_THREADS` · `TK_CONFIG_FAULT_CONTAIN` · `TK_CONFIG_PREEMPT_MODE` > · `TK_CONFIG_SCHED_POLICY` · `TK_SMP_CORES`. *(How a selector is paired with an arch > capability into a single `TK_FEAT_*` gate is [kernel-tiers.md](kernel-tiers.md) §6 — > stated once there.)* **(d) Instance / config tables.** Data-driven parameterisation — a brick configured without code coupling. The board supplies a table; the brick reads it. > `boards//tk_*_cfg.h` (`g_port_config`, the `tk_dio_cfg.h` channel manifest). **(e) Opaque handle + syscall (the confinement stud).** The narrow, kernel-mediated interface between a confined app and the kernel — closes the confused-deputy hole. Specified in [kernel-tiers.md](kernel-tiers.md) (`[rt]` syscall path + kernel-owned sync handles). **(f) The `arch_*` contract.** The kernel's one-way stud to the CPU. > `arch_context_{init,switch,start}` · `arch_irq_{save,restore,enable,disable}` · > `arch_core_id` · `arch_ipi` · `arch_kernel_tick` · `arch_timer_init` · `arch_mpu_init`. > Its board-vs-arch ownership split is [board-contract.md](board-contract.md) §4. **(g) The status contract.** One uniform return type across every brick: `tk_status_t` (`kernel/tk_status.h` — shared vocabulary, borrowed by the HAL per §2's pure-type exception). *(The fuller DET/error-detail module is `_legacy`-only; the minimal root keeps the coarse status enum + a kernel-side `kernel/tk_det.h` DET hook, off by default via `TK_CONFIG_ENABLE_DET`.)* --- ## 5. The glue anti-patterns — forbidden Glue is anything that makes two bricks inseparable. The tells: 1. **A lower brick names a higher brick.** The dependency-inversion smell. *Live examples in §8.* Fix: invert it into a stud (§4a). 2. **Shared mutable god-state.** A global struct many bricks read/write couples them. Keep per-brick state private; connect via studs, not a shared object. 3. **Mega-init.** One `init()` that initialises everything forces pulling every brick. Keep per-brick init; the app calls only what it uses. 4. **Mega-header.** A header that includes everything makes the include graph the dependency graph. Each brick exposes a narrow header. 5. **Lumped source groups.** Globbing separable bricks into one build group fuses them for the linker even if the code is clean — e.g. a board's kernel-facing tick `.c` swept into the same board group as its pure HAL-port. --- ## 6. Invariants and their enforcement The contract is only real if the build proves it. Three invariants, one mechanism: - **I1 — Independent selection.** Every `TK_CONFIG_*` toggles on/off without breaking another brick. If turning B off breaks A, A had glue on B. - **I2 — Links alone.** Each brick (and each layer subset) links with only its declared transitive dependencies present — nothing else. The HAL↔kernel seam: a **sovereign** build must reference **zero** kernel *link* symbols. (Borrowing `kernel/tk_status.h` is a compile-time TYPE include — it emits no link symbol — so a sovereign HAL build still satisfies this; it only needs `kernel/` on the include path. See §2.) - **I3 — One-way DAG.** No brick references a symbol from a brick above it. **The intended enforcement is a minimal-build matrix** — small builds, each selecting one brick / feature / layer subset, that must compile **and link**, so coupling becomes a *build error*, not a review opinion. The design (`_legacy/tools/composability_matrix.sh`) runs four checks: I3 static (a per-board scan that no HAL-port names a kernel symbol), I2 dynamic (a sovereign app links HAL-only, per board), I1 dynamic (each `TK_CONFIG_*` feature builds alone on host), and I4 (board pin/clock config validates + its generated file is fresh). The static half is `_legacy/tools/composability_check.sh`. > **Migration status (honest).** Neither tool is at root — `tools/` currently holds only > `SEGGER/` + `bin2uf2.py`, and the probe examples the matrix drives (`hal_probe`, > `feature_probe`) are not in the root `examples/` set. So at root the matrix is a > *specified* mechanism, not a running gate. Standing it back up (port the two scripts + > add a sovereign probe + a feature probe) is the open work that turns §7 from a > hand-audit into a build invariant. --- ## 7. Current state — an honest scorecard **Clean / proven:** - The layer model and the connector vocabulary of §4 — all in use at root. - The `TK_CONFIG_*` selectors of §4c (verified in `kernel/tk_config.h`). - The confinement stud — unprivileged-thread isolation (kernel-RAM `.privileged_bss`, per-thread stacks); see [board-contract.md](board-contract.md) §3.2 for the boards it is proven on. - **Sovereign-capable boards exist by construction.** A board with no kernel-facing `.c` (e.g. `boards/host`, `boards/qemu_riscv32_virt` — pure HAL-port + `tk_console`) references no kernel symbol. **Known glue — present at root (the retrofit was not carried into the reshape).** The kernel-facing board files were, in the legacy tree, split into `boards//kernel/` to keep the HAL group kernel-free. The reshaped root flattened the board directories, so that glue is live again inside `TK_BOARD_SRCS`: - `boards/nrf52832_pca10040/tk_tick_nrf52.c` calls `arch_kernel_tick()` from `RTC1_IRQHandler` — the §8 worked example, verbatim. - `boards/rp2040_pico/tk_smp_rp2040.c` `#include`s `tk_kernel.h` + `tk_kernel_port.h` and calls `tk_kernel_start_this_core()`. An I3 scan would flag both today. The **planned resolution** is not another `boards//kernel/` move but a per-arch multicore tier (`arch////`), scoped in ADR [0002](../decisions/0002-multi-instance-kernel.md) §7 — which calls the rp2040 case out as "the invariant's one leak, self-documented in the file." The trap-frame boards (RISC-V) and the host are clean by construction (the arch owns the tick; no board→kernel call). **Enforcement status:** I3 is *designed* (the static scan) but not running at root; I1/I2 likewise. Composability at root is *enforced by convention + code review*, with the build-invariant machinery a migration away (§6). --- ## 8. Worked example — nRF52 tick: glue → stud **Now (glue), verbatim at root:** the board's tick ISR reaches *up* into the kernel. ```c /* boards/nrf52832_pca10040/tk_tick_nrf52.c — a board HAL-port file */ void RTC1_IRQHandler(void) { ...; arch_kernel_tick((uint8_t)RTC1_IRQn); } /* names a kernel/arch symbol */ ``` The file cannot stand alone in a sovereign build, because it has fused itself to the kernel's tick entry. **Target (stud, §4a):** invert the direction. The HAL timer exposes a callback; the kernel — *when present* — registers `arch_kernel_tick` as that callback. The HAL-port names nothing above it. ```c /* HAL timer brick: exposes a stud */ void tk_timer_set_tick_handler(void (*fn)(uint32_t)); void RTC1_IRQHandler(void) { ...; if (s_tick) s_tick(RTC1_IRQn); } /* names nothing above */ /* kernel start (only when a kernel is present): plugs in */ tk_timer_set_tick_handler(arch_kernel_tick); ``` Same behaviour; opposite dependency direction. No handler registered ⇒ no kernel ⇒ the timer brick still links and runs. This is the pattern k2 already uses for the DIO edge IRQ and the idle hook — the tick simply never adopted it. On the trap-frame archs the question is moot: the arch owns the timer vector, so there is no board→kernel call to invert (see [board-contract.md](board-contract.md) §4 ★). --- ## 9. Open work (the path to "build without fear") 1. ~~Write this contract.~~ (this document) 2. **Migrate the enforcement to root:** port `composability_check.sh` (I3 static) + `composability_matrix.sh` (I1/I2/I4) from `_legacy/tools/`, and add the sovereign `hal_probe` + `feature_probe` examples they drive. 3. **Resolve the known glue** per ADR 0002 §7: move the Cortex-M kernel-facing board files (nRF52 tick, rp2040 SMP) into a per-arch multicore tier so a sovereign Cortex-M link references zero kernel symbols. 4. **Optional polish:** the tick callback-inversion (§8) to give a sovereign Cortex-M app a HAL timebase. After (2)+(3), the foundation's state stops being a matter of belief: the build reports it, every time.