# Portable security & safety API — the explicit-control model **Status:** design doc / north star (proposed 2026-08-31). Defines the application-facing vocabulary the kernel exposes for hardware security & safety features, the division of labour between the vendor SDK and the kernel, and the rule for hardware that lacks a given feature. Extends [`sketches/amp/mcxn947/protection-api-discussion.md`](../../sketches/amp/mcxn947/protection-api-discussion.md) and [`lowering_prototype.c`](../../sketches/amp/mcxn947/lowering_prototype.c) by adding **world** (Secure/Non-secure) as a first-class axis and making the kernel — not the SDK — **own the hardware setup**. Mechanisms already HW-proven: [ADR 0003](../decisions/0003-supervisor-fence-seam.md) (MPU fence), [ADR 0005](../decisions/0005-system-side-secondary-core-fence.md) (TrustZone/AHBSC fence), G1/G2a (`examples/frdm_mcxn947/`). --- ## 1. Principle An application engineer controls the hardware's security & safety features through **explicit, visible, human-readable kernel API** — in terms of *kernels, threads, privilege, cores, affinity, world* — **not** through a vendor config tool that programs the silicon behind the scenes. The same API compiles and runs on **any supported architecture** (ARM, RISC-V, TriCore, …); each hardware feature (MPU, TrustZone, PMP, …) is an **optional capability** the API drives where present and refuses or ignores where absent. The kernel **owns** the programming of that hardware; the vendor SDK is reduced to bare boot. The invariant is **visibility and control**, not kernel-exclusivity: the engineer *chooses* who configures the silicon — the kernel from `main()`, the SDK's GUI tools, or their own pre-kernel register code (§2). What must never happen is a security-relevant setting buried in a generated file the engineer cannot see, replace, or override. And the default vocabulary is **security-concept-centric and architecture-agnostic** — arch- specific features (e.g. TrustZone's second *world*) are explicit, capability-gated extensions, not the base. ## 2. Who owns the setup — a spectrum, the engineer chooses The kernel does not *demand* to own the hardware setup; the engineer decides who does. Three modes, all first-class: | Mode | Who programs SAU/AHBSC/MPU/ITNS/core-release | Kernel's role | |---|---|---| | **Kernel-owns** | the **kernel**, from the app's API declarations | **owner**: programs the partition and takes responsibility for enforcing it | | **App/SDK-owns** | the **vendor SDK** (incl. its GUI config tools) or the app's own bare-metal register code, *before* kernel init | **guest**: runs on the pre-configured environment, uses what it is given, verifies it (below) | | **Hybrid** | some pre-configured, the rest by the kernel | owner of what it was asked to own; guest for the rest | So "the kernel owns the programming" means it *can* — not that it *must*. Hand it a pre-configured world (an SDK that generated `BOARD_InitTrustZone` + per-core `main_coreN()` entries, à la the TriCore tooling; or hand-written register setup) and the kernel is a **guest**: it can be instantiated with *no* settings at all — just create threads and schedule them — running within the given partition. Boot minimally instead (clock + reach `main()`) and ask the kernel to configure everything, and it **takes ownership**: it programs the partition from the API declarations and owns responsibility for it. The vendor SDK is *reducible* to bare boot — not *required* to be. Either way the partition is **visible and controllable** — in the engineer's `main()` (kernel-owns) or in the engineer's own pre-kernel setup (app-owns) — never hidden. ### Prerequisites: the kernel is *given* the memory map — it does not invent it In every mode the kernel needs the memory layout handed to it, not guessed. The **`.ld` linker script** defines the areas (secure code, secure RAM, NS code, NS RAM, per-island arenas, the shared window); these reach the kernel as **addresses or linker symbols**. The kernel's job is to **enforce** the partition on the hardware and to **verify** the layout meets its requirements — never to choose the layout. Verification is **fail-fast**: - **compile time** where possible — `static_assert` on sizes / alignments / region relationships (a 4 KiB-aligned per-island kernel region; an arena that fits its bank; a shared window outside every arena); - **runtime, on first execution** for what the linker cannot check — probe the actual hardware/security state and report any inconsistency **immediately** (fail loud), before scheduling a single thread. So a prerequisite mismatch (a missing symbol, a misaligned region, an arena overlapping the shared window, a requested `world` with no linker-provided NS image) surfaces at build or at bring-up — never as a mysterious fault later. When the kernel *takes ownership* it also absorbs the job of the SDK's `BOARD_InitTrustZone()` (SAU + AHBSC + ITNS), programming it from these given areas at a kernel init/start hook (linchpin: §9). ## 3. The vocabulary (first-class axes) Each axis is a named concept with an API surface and a backing hardware capability. Real names exist today unless marked **NEW** (proposed). ### core — a physical execution unit `TK_CORE(n)` (a mask), `arch_core_id()`, `tk_kernel_owned_cores(k)`. Each core carries capability flags the kernel queries (has an MPU? a security extension? a PMP?). Cores are *owned by* kernels (below), never addressed directly by app logic beyond affinity. ### kernel (instance) — a scheduling domain owning a set of cores `tk_kernel_init()` (the default instance), `tk_kernel_define(cores, name, &err)`, `tk_kernel_start()`, `tk_kernel_self()`. One core → an AMP island; several cores → an SMP cluster; the two compose (an AMP island *beside* an SMP cluster — HW-proven on AURIX TC275). The set of instances *is* the SMP-vs-AMP decision, expressed, not configured elsewhere. ### thread — a unit of execution inside one kernel `tk_thread_create_in(k, fn, stack, size, prio)`, `tk_thread_create_attr_in`, `tk_thread_spawn_in`. A thread lives in exactly one instance's pool for its whole life and runs on that instance's cores (per affinity). ### affinity — which of its kernel's cores a thread may run on `tk_thread_set_affinity(t, TK_CORE(..))`, `tk_thread_pin(t, core)`. Default = the creating core (a new thread cannot be grabbed by a peer before it is placed). ### privilege — a thread is privileged or unprivileged *(capability: MPU / PMP)* `tk_thread_set_privileged(t)`; unprivileged is the default under `TK_CONFIG_UNPRIV_THREADS`. An unprivileged thread is confined by the core's MPU/PMP (cannot reach kernel RAM or peripherals; must syscall). **Within** one kernel — the boundary between a thread and its own kernel. Enforced identically by whatever unit the arch has (ARMv8-M MPU, ARMv7-M MPU+SRD, RISC-V PMP+U-mode); the app writes one call, unaware which. ### world — Secure or Non-secure *(capability: TrustZone-M / TrustZone-A)* **NEW** `tk_kernel_set_world(k, TK_WORLD_SECURE | TK_WORLD_NONSECURE)` (or a `tk_kernel_define` argument). A security **domain**, assigned to an instance. The kernel programs the SAU + system security controller (AHBSC) so that instance's cores execute in that world and its memory carries that attribution. **Between** kernels — the coarsest boundary, orthogonal to privilege. The trusted (Secure) instance can observe the Non-secure one; the Non-secure one cannot reach Secure memory (asymmetric — ADR 0005). This is the axis the engineer was missing: *"this island runs Secure, that one Non-secure,"* said in one call. > **Naming / scope (DECIDED 2026-08-31: keep `world`).** `world` is intrinsically a *second* security > dimension, orthogonal to privilege — and most arches have only privilege (RISC-V M/S/U, TriCore > user/supervisor are privilege ladders, not a second world). So `world` is a **capability-specific** axis, > not a universal one: the vocabulary stays security-concept-centric and arch-agnostic *by default*, and > `world` is the deliberate arch-specific extension, capability-gated (§4). The term **`world`** is kept > (over `security_domain` / `trust_domain`): abstracting the name buys nothing on arches with no second > domain, and naming the thing for what it is — TrustZone — is clearer and honest. ### isolation — freedom-from-interference between kernels *(capability: MPU / paging / world)* `tk_kernel_set_fence(k, view)` / today's `tk_kernel_set_ram(k, base, size)`; posture `ISOLATED` / `TRUSTED` / `OPEN` (see `lowering_prototype.c`). Declares that an instance's private RAM must be unreachable by other instances' *kernels*. The kernel **lowers** the intent onto whatever mechanism the cores have: a per-core MPU deny-list (symmetric), paging, or — when a peer core has no unit — the **world** mechanism (mark the protected instance Secure). So *world and isolation meet here*: on TrustZone hardware, assigning worlds is **how** isolation is delivered; on symmetric hardware, the MPU delivers it and `world` is absent. ### shared region — memory reachable across a boundary **NEW** `tk_kernel_add_shared(base, size)`. The deliberate window both islands/worlds may touch. Not an instance, not fenced; on TrustZone it stays Non-secure/unmarked so every master reaches it. A *sanctioned* interference channel — the trusted side validates what the untrusted side writes. ### interrupt targeting — which world/privilege an ISR runs in *(capability: NVIC-ITNS / TrustZone)* **NEW** `tk_irq_set_world(irqn, world)`. Which world handles an interrupt (`NVIC->ITNS`), owned by the kernel rather than the SDK's generated table. Absent TrustZone, collapses to "all interrupts one world." ## 4. The capability rule — loud vs collapse Every axis binds to a hardware capability that may be absent. Two distinct behaviours, never a silent lie: - **You explicitly request what the silicon cannot do → LOUD.** `tk_thread_set_privileged` on a core with no MPU, or `tk_kernel_set_world(SECURE)` on RISC-V/TriCore, returns `TK_ERR_UNSUPPORTED`; a requested island fence that cannot be installed fails closed (`tk_instance_fence_reject`). The app learns the truth. - **You don't use an axis → COLLAPSE.** No world declared → no TrustZone programming, byte-identical build. No fence declared → no fence. The feature adds nothing when unused. (This is the kernel's existing collapse property + `ARCH_HAS_*` capability flags.) **Fail vs ignore is the engineer's call, per intent** (the open question you raised). For cross-architecture source, "request an arch-specific feature on a part that lacks it" should sometimes be fatal and sometimes a graceful skip — only the engineer knows whether the feature is *load-bearing* for their safety case: - a **required** intent (`world = SECURE` because the partition is only safe with TrustZone) → LOUD: the setup call reports `TK_ERR_UNSUPPORTED`, and if the engineer relies on it the enforcement point fails closed. Running weaker-than-asked is never silent. - a **best-effort** intent (`use TrustZone if present, else the MPU fence, else run flat`) → graceful no-op; the kernel delivers the strongest boundary the part supports and reports what it did. **DECIDED (2026-09-01): the fallback flag names the OUTCOME, `tk_fallback_t = TK_FALLBACK_ABORT / DEGRADE`.** The earlier `BEST_EFFORT` wording was rejected as actively wrong for this domain: it implies a *probabilistic* boundary ("we'll try to keep you isolated"), whereas a security/safety boundary is always present (1) or absent (0) — a `TK_FEAT_ISOLATION`-style binary, never a maybe. This flag is **NOT** fuzzy enforcement; it is a **setup-time decision over the platform's BINARY answer** to "can I provide this boundary?". A capability-gated call takes it explicitly: - **`TK_FALLBACK_ABORT`** (the **default** — fail-safe: forget to think about it and you get the safe behaviour): the platform's "no" is a **hard stop** — `TK_ERR_UNSUPPORTED` at setup, fail-closed (park the core) at `start`. Safety-load-bearing calls use this. *(HW-proven: W-fail.)* - **`TK_FALLBACK_DEGRADE`** (opt-in for portable source): the platform's "no" means run in a **defined** degraded mode **and emit a diagnostic** — never silent. The **achieved** state is queryable (`tk_kernel_world` returns what the platform actually delivered, never the requested world), so the boundary is honestly 0, not pretended 1. *(HW-proven: W-besteffort.)* The boundary itself, and the achieved state, stay binary and readable at all times; the flag only chooses what to do about a platform "no". The raw **status return** remains for engineers who want to branch themselves; and any *declared isolation guarantee* that cannot be delivered fails closed at `start` under ABORT. So "any ARM hardware that supports (or optionally supports) MPU / TrustZone" is exactly this: the API is one surface; each call is honoured, refused loud, or collapsed, per the core's capability flags — never mis-mapped to a weaker mechanism. ## 5. The kernel owns the hardware setup The kernel programs the security hardware from the declared configuration, through arch/board seams, at `tk_kernel_start()` (and a pre-start setup hook) — **not** a generated `SystemInitHook`. Seams: - **arch seam** (portable, per-arch): `arch_instance_fence_apply(...)` (per-core MPU/PMP/paging deny), `arch_thread_region_set(...)` (per-thread privilege region), and `arch_self_is_unprivileged()` etc. - **board/SoC seam** (non-architectural silicon): a `board_security_setup(config)` + `board_instance_fence_*` that programs SoC IP — on MCXN947 the **SAU + AHBSC + NVIC-ITNS + core-release**, i.e. what `BOARD_InitTrustZone` does today, but driven by the kernel from the app's `world`/`shared`/isolation declaration. **No MCXN947 register poking enters the portable kernel/arch** (ADR 0005 §3). The vendor SDK's `BOARD_InitTrustZone()` is thereby retired from the boot path; its knowledge moves into the board seam the kernel calls. The engineer sees the partition declared in `main()`; the kernel makes it real. ## 6. One image or two — the build-shape a `world` split forces `world` is the one axis that can change the *number of binaries*, because a memory region is Secure **or** Non-secure and a core executes in the world of the code it fetches: - **No `world` split (symmetric board, or all-Secure):** ONE image. SMP or multi-instance AMP live in a single binary with a single `main()`; the kernel schedules/partitions inside it. (Plain MCXN947 SMP/AMP demos; every non-TrustZone arch.) - **A `world` split (TrustZone, Secure + Non-secure instances):** TWO images — a Secure image (the trusted instance, owns the setup + programs the partition) and a Non-secure image (the confined instance's threads). The Secure `main()` **declares** the whole partition and releases the NS core into the NS image (G2a). The API spans two binaries of *one project*; it does not pretend they are one. The API is identical in both shapes; only the build/link output differs, decided by whether a `world` split was declared. This is a property to surface, not hide. ## 7. Worked examples (the engineer's scenarios) **(a) SMP with privileged + unprivileged threads — any MPU arch, one image** ```c main() { // one entry; kernel schedules across cores tk_kernel_init(); // one kernel, owns all cores (SMP) t = tk_thread_create(worker, ...); // unprivileged by default (UNPRIV build) tk_thread_set_privileged(mon); // this one privileged tk_kernel_start(); // MPU/PMP enforces privilege per thread } ``` Runs unchanged on ARMv7-M, ARMv8-M, RISC-V — the arch's unit implements privilege; the app is unaware which. **(b) AMP — one kernel per core (+ optional SMP cluster), one image** ```c tk_kernel_init(); // island A: core 0 B = tk_kernel_define(TK_CORE(1), "B", 0); // island B: core 1 (AMP) C = tk_kernel_define(TK_CORE(2)|TK_CORE(3), "C", 0); // island C: cores 2-3 (SMP cluster) tk_kernel_set_ram(A, arenaA, sizeA); // isolation: each island's private RAM tk_kernel_set_ram(B, arenaB, sizeB); tk_kernel_start(); // per-core MPU fences applied ``` **(c) AMP + TrustZone — Secure + Non-secure islands, two images** ```c // Secure image, core 0 — the ONLY security-relevant boot is: hand control to the kernel main() { tk_kernel_init(); // island A: core 0 B = tk_kernel_define(TK_CORE(1), "B", 0); // island B: core 1 tk_kernel_set_world(A, TK_WORLD_SECURE); // explicit: A is Secure tk_kernel_set_world(B, TK_WORLD_NONSECURE); // explicit: B is Non-secure tk_kernel_add_shared(mailbox, mbsize); // the open window tk_kernel_start(); // kernel programs SAU+AHBSC+ITNS, marks A Secure, releases core1 into the NS image } ``` On a board *without* TrustZone the two `set_world` calls return `TK_ERR_UNSUPPORTED` (loud) — the app knows this partition needs TrustZone. On MCXN947 the kernel owns every register `BOARD_InitTrustZone` touches. ## 8. Portability matrix (illustrative) | arch | privilege (MPU/PMP) | world (TrustZone) | isolation mechanism | |---|---|---|---| | ARM v8-M (M33) | ✅ PMSAv8 MPU | ✅ TrustZone-M + SAU | MPU deny, or world (AHBSC) for a unit-less peer | | ARM v7-M (M3/4/7) | ✅ PMSAv7 MPU | ❌ (`set_world`→UNSUPPORTED) | MPU deny | | ARM v6-M (M0+) | ❌ (`set_privileged`→UNSUPPORTED) | ❌ | none (collapse) | | ARM-A / R (VMSA / PMSAv8-R) | ✅ | ⚠ TrustZone-A (different) | paging / region deny | | RISC-V | ✅ PMP + U-mode | ❌ | PMP/paging deny | | TriCore (AURIX) | ✅ DPR + User mode | ❌ | DPR deny | | C2000 / MSP430 / host | ❌ | ❌ | none (collapse) | The app writes the same calls; this table is what each honours, refuses, or collapses. ## 9. Where this stands — proven, and the linchpin - **Proven mechanisms:** privilege via MPU/PMP (multi-arch, HW); MPU island fence (ADR 0003, 4 arches HW); a kernel running in the Secure world + marking AHBSC rules at runtime (**G1**, HW); a Secure core releasing the other core into Non-secure code that runs (**G2a**, HW). - **The linchpin — PROVEN (GL, HW):** the kernel **owns** the TrustZone setup — `BOARD_InitTrustZone` runs from `main()`, not the SDK's `SystemInitHook` (self-verified: MISC_CTRL `0x86AA`→`0x86A6`). "The SDK secretly configures security" is now "the kernel controls it, visibly, from `main()`." - **The `world` axis — PROVEN end-to-end (W3/W4, HW 2026-08-31).** The axis is now real calls: `tk_kernel_set_world(k, TK_WORLD_SECURE|NONSECURE, flags)` and — for a dual-image confined secondary that runs its OWN image — `tk_kernel_launch_secondary(phys_core, entry, flags)`. At `tk_kernel_start` the kernel lowers them onto two weak board seams (`board_world_protect` = the self-mark; `board_core_release` = the secondary release), the single portable-kernel change (ADR 0005 §3), capability-gated + collapse-safe (fence matrix PASS, RISC-V fence demo still holds, `TK_FEAT_WORLD` off = byte-identical). On FRDM-MCXN947: the Secure island A marks its own RAM and launches the Non-secure CPU1 into its image with **zero AHBSC in the app**; a benign CPU1 island coexists indefinitely (W3), and a rogue CPU1's Non-secure write to A's Secure block is fenced with A's data INTACT (W4). ⭐ A HW run also corrected the model: a confined secondary is NOT a `tk_kernel_define` instance (that would carve a scheduler core of the secure kernel's own machine) — it is an *external secondary* the secure kernel launches + confines, so the secure kernel stays single-core. - **Remaining:** `tk_kernel_add_shared` / `tk_irq_set_world` as first-class calls; graceful **containment** of a violation (today the part takes an ITRC fail-secure reset — W5/ITRC follow-on); kernel-owned SAU *region* programming (the checking *enable* stays boot-gated per the GLIKEY findings). The measure of done: an engineer partitions a multicore ARM part across Secure/Non-secure worlds, SMP/AMP instances, and privileged/unprivileged threads **entirely through this vocabulary**, and the same source compiles to a weaker-but-honest configuration on a part that lacks MPU or TrustZone — with the vendor SDK doing nothing but booting the clock.