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 (the header/symbol
split), board-contract.md (the board↔kernel boundary),
hal-architecture.md (HAL internals), ADR
0002 (§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 (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/board/<board>.mk) there are three:
group |
defined in |
contents |
|---|---|---|
|
|
|
|
|
the CPU port ( |
|
|
the board’s HAL-port |
Note — there is no
TK_HAL_SRCSat root. The portable HAL is header-only (hal/*.h) plus the one sharedhal/tk_console.c; every concrete peripheral driver is a per-board.cswept intoTK_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/<board>/):
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.cserves every board).Leaf brick — the uniform return contract
tk_status_t. It lives inkernel/tk_status.h(the shared type-vocabulary home, besidetk_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
_legacyHAL —tk_spi/tk_i2c/tk_adc/tk_pwm/tk_gpt/crypto/tk_wdgthe
tk_error/DET module — is not in the minimal root. It is_legacy-only; see 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; this doc owns only the linkage/selection law.
Arch bricks (arch/<cpu>/): 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.
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) · theALIAS_DEFAULTvector handlers in eachboards/<board>/startup_*.c· the weakarch_timer_inita 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 singleTK_FEAT_*gate is 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/<board>/tk_*_cfg.h(g_port_config, thetk_dio_cfg.hchannel 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 ([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 §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:
A lower brick names a higher brick. The dependency-inversion smell. Live examples in §8. Fix: invert it into a stud (§4a).
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.
Mega-init. One
init()that initialises everything forces pulling every brick. Keep per-brick init; the app calls only what it uses.Mega-header. A header that includes everything makes the include graph the dependency graph. Each brick exposes a narrow header.
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
.cswept 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.his a compile-time TYPE include — it emits no link symbol — so a sovereign HAL build still satisfies this; it only needskernel/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 onlySEGGER/+bin2uf2.py, and the probe examples the matrix drives (hal_probe,feature_probe) are not in the rootexamples/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 inkernel/tk_config.h).The confinement stud — unprivileged-thread isolation (kernel-RAM
.privileged_bss, per-thread stacks); see 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/<board>/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.ccallsarch_kernel_tick()fromRTC1_IRQHandler— the §8 worked example, verbatim.boards/rp2040_pico/tk_smp_rp2040.c#includestk_kernel.h+tk_kernel_port.hand callstk_kernel_start_this_core().
An I3 scan would flag both today. The planned resolution is not another
boards/<board>/kernel/ move but a per-arch multicore tier (arch/<cpu>/<mp>/<soc>/),
scoped in ADR 0002 §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.
/* 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.
/* 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 §4 ★).
9. Open work (the path to “build without fear”)¶
~~Write this contract.~~ (this document)
Migrate the enforcement to root: port
composability_check.sh(I3 static) +composability_matrix.sh(I1/I2/I4) from_legacy/tools/, and add the sovereignhal_probe+feature_probeexamples they drive.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.
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.