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 and 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 (MPU fence), ADR 0005 (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

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

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

// 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.