Skip to content

Added the ZoneX Phase-0 partitioning demonstrator - #5

Merged
fdesbiens merged 40 commits into
eclipse-threadx:devfrom
fdesbiens:feature/zonex-core
Sep 9, 2026
Merged

Added the ZoneX Phase-0 partitioning demonstrator#5
fdesbiens merged 40 commits into
eclipse-threadx:devfrom
fdesbiens:feature/zonex-core

Conversation

@fdesbiens

Copy link
Copy Markdown
Contributor

ZoneX is a deterministic partitioning hypervisor for Armv8-R, and this is the whole of its Phase 0: 37 commits introducing a new component into an empty repository. It is a component introduction rather than a change, so this describes what is here rather than what moved.

This is not production software and is not close to it. It is a demonstrator, built to establish that a small set of mechanisms work on real Armv8-R silicon and to measure what they cost.

What it does

Two ThreadX kernels run at EL1 on one logical Cortex-R52 core, each confined to its own stage-2 window, time-sharing the core under a static major frame taken from a manifest — on the Armv8-R AEM FVP and on the NXP S32Z280-594EVB. Partition A holds seven ticks of every ten and partition B three, and each partition's clock advances by its own windows and by nothing else.

Neither partition can read, write or execute the other's memory or the hypervisor's, and not because it is asked not to: a partition that grants itself its neighbour's memory in its own EL1 MPU is still refused by stage 2, stopped, and reported with the partition, the address and the guest PC. The other partition runs to the end of the frame with its schedule untouched.

A window ends whether the partition agrees or not. The hypervisor's timer sits in GIC Group 0, so it arrives as an FIQ at EL2, and with HCR.FMO set PSTATE.F is ignored at EL0 and EL1. One build proves it by trying: a partition that masks IRQ and FIQ and spins for ever is preempted exactly on schedule, and all its masking costs it is its own kernel's tick.

What it measures, and the one thing that is wrong

A regression sweeps fourteen isolation cases in one run and measures the critical partition's window period continuously while the untrusted one is steered through five behaviours. On the board, over six hundred major frames, A's 800,000-count period moves by 22 counts while its neighbour idles, 24 while that neighbour computes with its own interrupts masked, and 185 while it commits a hundred and three thousand boundary violations. An independent run six days earlier, on an earlier revision, gave 21, 22 and 304 with the same 22-byte console hypercall — so these are a reproduced measurement rather than one run's luck.

Nothing a partition does through the schedule reaches its neighbour. Computing, masking its own interrupts and violating its boundary without pause each move the critical partition's period by tens of counts.

What reaches it is the hypervisor's own console driver. A guest that prints moves that period by up to one line tag — 22 bytes at 115,200 8N1, 17,640 counts of the board's 8 MHz counter — every run. That is a defect in ZoneX, not a limit of the partitioning, and it is bounded, derived and reproducible.

That defect is in the repository with its mechanism named, its cost measured and its cure scoped, rather than smoothed out of the claim. docs/decisions.md D31 has the measurement; docs/wcet-inputs.md section 4 has what removing it takes — an interrupt-driven driver with a polled fallback the fault path can force, because the fault reporter prints at the moment ZoneX has already failed once.

It is tracked as the next change rather than as part of a later phase, and deliberately so: it is far smaller than any phase, it is the highest-value change available at its size, and grouping it with work of a different order of magnitude is the surest way to leave the qualification standing indefinitely.

What it does not do

Dual-core lockstep presents as one logical core, so this is temporal and memory partitioning on a single core. It is not spatial partitioning across multiple cores; that needs split-mode SMP and is deferred. Interrupt virtualisation with a bounded worst-case latency, inter-partition communication, the full time-partition scheduler, supervised partition restart, TraceX integration and the safety-artifact package are later phases.

Interrupt latency is not measured at all: guest interrupts go straight to EL1, and bounding them needs the GIC List Registers this phase does not use.

Every timing figure comes from one part on one bench, with the EL2 caches off, built -Og, and with no clock tree configured — the core runs on the part's power-up RC oscillator. They are a first measurement with its conditions stated, not characterisation.

How it is tested

Two suites, and which one a change belongs in is deliberate.

  • Host suite, test/host/run.sh — the architecture-independent logic. 8 tests. 100% of lines and 100% of branches over the seven fully reachable files in core/src, enforced as a floor by the build rather than reported. Branches are the half that does the work: the failures those files exist to prevent live in the arms nobody took.
  • FVP suite, test/fvp/run.sh — 24 tests on the Armv8-R AEM FVP, each judged by the image's own self-reported verdict. 24/24.
  • Silicon, test/s32z280/run.sh — the same images on the S32Z280-594EVB, loaded and judged over a debug probe. This is where every timing claim comes from; a green model run is not evidence about a part.

The suite includes builds that must fail, registered as such, because a check that has never been seen to fail is not evidence that it can: a violation aimed at an address the payload is granted, an image told it needs more MPU regions than exist, a manifest whose two windows overlap, a hypervisor whose own tick cannot be delivered, a region limit one granule too generous, the per-partition time freeze removed, a shared read-only granule its reader may write, and ZoneX faulting inside its own fault report. Each must report failure on the check it was built to violate and on no other. One starting to pass fails the suite.

The port itself — CP15 assembly, the EL2 vectors, MPU programming, the context switch — is not gcov-measurable from a workstation, and instrumenting it on the target would change the code generation of the thing being measured. docs/coverage.md records which files are held to the floor, which are not, and what stands in for them. No number in this repository should be read as structural coverage of the port; that needs its own tooling and is a later concern.

Verified on this revision

Host suite: 8 tests, 100% of 974 lines and 469 branches, enforced as a floor, under the pinned gcovr and GCC 14 with warnings as errors. FVP suite: 24/24. Silicon, on the tree this pull request publishes: the probe, the two-partition image and the six-hundred-frame regression all pass; HMAIR0 reads 0x004400ff, which is the value the port's static assertions bind; and two negative builds each fail on exactly the check they were built to violate and on no other.

The silicon run was taken because three of these commits moved the memory-type vocabulary and the region granule between headers. Those moves are behaviour-neutral and their agreement is asserted at compile time, but only a real part confirms the programmed memory attributes still work — and legible console output is that confirmation, since Normal attributes corrupt it.

Certification posture

Written MISRA-aware and coverage-friendly from the first commit, because the question a safety audience asks first is whether the code was shaped for it.

  • Errata swept and written down, at stated revisions: the Cortex-R52 Software Developer Errata Notice and the S32Z2 mask-set errata for the mask on this board. MIDR reads r1p3 and is printed by the probe on every run, so a different part reports itself rather than inheriting the conclusion. Seven Cortex-R52 errata are open at r1p3; four are debug and trace only; the three in scope do not apply, and the reasons are in docs/errata.md so a reader can check the reasoning rather than take the conclusion. Two standing constraints fall out of the sweep and are recorded for code not yet written.
  • The largest term in a partition switch is a fixed cost. The EL1 MPU save and restore is 3,407 of 5,703 cycles, and its trip count is read once at boot from MPUIR — a property of the part, not of a guest, not of a manifest, and not of anything that can change during a run.
  • docs/wcet-inputs.md is the input to the worst-case-execution-time work: every path a switch or a trap can take, read with one question — is anything here data-dependent? — with what is known about each. It is not a WCET analysis; it is the list of places one would have to start, so that later work begins from measurements rather than a fresh reading of the source.
  • No heap, no external dependencies, no allocation anywhere. The largest single stack frame in the tree is 80 bytes; a 256-byte ceiling is enforced at build time.

Conventions this repository settles

  • C17, not C99 — the first Eclipse ThreadX component born on that baseline rather than migrated to it. Extensions off, -Wpedantic in force.
  • Armv8-R AArch32, and there are no page tables at either stage. Both stages of address control are region-based MPUs. An A-profile hypervisor code shape is the wrong template, and scripts/check_terminology.sh rejects the register and concept names that belong to other architectures. It runs in CI, unfiltered, on every pull request.
  • docs/decisions.md records the design decisions and why each was taken, including the ones that were corrected by measurement. docs/armv8r-el2-reference.md is the verified register sheet — it exists so that no session re-derives an encoding, and it records what was measured where the manuals disagree. Three of those disagreements were consequential enough to change the code.
  • Linux only, deliberately. No Windows build and no PowerShell scripts.

Review notes

Squash-merge, per project convention. Every commit carries an Assisted-by: trailer: this work was AI-assisted, as the Eclipse Foundation's Generative AI Usage Guidelines require to be disclosed, and the file headers carry the matching AI Disclosure paragraph.

Companion documentation pull request: eclipse-threadx/rtos-docs-asciidoc#59

Closes #3. Closes #4.

ZoneX started as a repository with four files and no code. This is everything
that has to exist before the hypervisor can be written: the build system, the
C17 baseline, the verified Armv8-R EL2 register sheet, the recorded design
decisions, and CI that runs from the first commit.

No hypervisor logic is here, deliberately. Every translation unit is empty of
implementation and every one of them compiles, so the change that writes the
code opens a tree that already configures, builds and links in five
configurations. Writing any of it earlier would have meant writing it against
register names nobody had checked, and terminology bleed -- AArch64 spellings
on an AArch32 core, an A-profile hypervisor shape on an MPU-only architecture
-- is the single easiest way to get this architecture wrong.

docs/armv8r-el2-reference.md is the answer to that. Every line in it was read
out of the Cortex-R52 TRM or measured on the part, and each carries its
source. Two things it settles that matter for the design. There is no
HPRBAR.AP encoding that grants a guest access while denying EL2, so isolation
between partitions can only come from which regions are enabled while a
partition runs, never from the permission bits. And HSCTLR.BR=1 gives EL2 the
background map for its own accesses while EL0/EL1 misses still fault
regardless of BR, so ZoneX spends no regions on its own code and data -- which
matters when the hypervisor's own mapping and every guest's stage-2 mapping
share one 20-entry region set.

BR grants permission, not attributes, and that distinction is recorded because
the model hides it. Table 8-4 fixes the background map as Normal cacheable
below 0x60000000, and the S32Z280's console sits at 0x4298_0000 -- inside it.
So the hypervisor needs Device-attributed regions for its own console and GIC
even with BR set. On the FVP those devices land in the Device band and the
background map is accidentally correct, so a green model run cannot show that
the regions are missing. It is the same shape as the region-count trap, and
both are written down for that reason.

It also closes one of two documented ambiguities. The TRM's section 3.3.48
says direct access stops at HPRBAR15; section 8.4 lists HPRBAR16-HPRBAR24 at
opc1 = 5 and the register summary agrees. So the EL2 MPU avoids the penalty
measured at EL1 for high regions -- 542-604 cycles through PRSELR against
434-470 direct -- and the whole budget is directly addressable. The HPRENR
width question stays open: the TRM contradicts itself and only the part can
settle whether a bit above 15 is honoured or merely stored.

ZoneX is built to C17 with extensions off, the first component of the suite
born on that baseline rather than migrated to it, and the strict warning set
is in place from here rather than from a later hardening pass. Both are
cheaper now than later: retrofitting a codebase to a warning set costs far
more than building to one, and the certification back end for ZoneX is
funded.

CI exists before there is anything to run, for the same reason. The FVP
workflow builds and executes; there is no static check for "the partition
still runs", and ThreadX added its equivalent eight years in, after a period
where nothing in CI ran a single instruction of any port. Every workflow
triggers on pull requests against dev and main and on pushes to both --
ci_cortex_m.yml ran on master only while dev was the integration branch, so
it gated no pull request anybody opened and the ports drifted for eight
months.

scripts/check_terminology.sh is the mechanical half of the terminology
argument. It rejects the register and concept names that belong to other
architectures, and it runs as its own CI job so its answer is unambiguous.
Its one exception is deliberate: a suite build option may legitimately end in
_EL1 -- TX_R52_BOOT_AT_EL1 is the ThreadX option that builds a guest starting
there, and the guest work has to name it repeatedly -- while an AArch64
system register never carries a component prefix, so requiring the absence of
one separates the two.

CONTRIBUTING.md's "Building and testing" and "Continuous integration"
sections described a repository with neither. They now describe what is here.
Its C99 line is corrected to C17 for ZoneX, the sentence implying a PowerShell
script set is qualified -- Phase 0 is Linux, FVP and silicon, and an untested
build_host.ps1 would be a promise the project cannot keep -- and its section
on agent-instruction files is replaced by one that does not claim a file this
repository does not carry. No other Eclipse ThreadX repository ships agent
instructions, and this one does not either; CONTRIBUTING.md is the single
authority, and it now says what to point an agent at.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
An EL2-resident ZoneX now programs stage-2 MPU regions, drops to EL1, and a
stage-2 violation by the EL1 payload is taken to EL2, decoded and reported by
name -- on the Armv8-R AEM FVP and on S32Z280-594EVB silicon. Nothing else in
Phase 0 was worth building until that worked, because everything else assumes
it. There is still no ThreadX guest, no partition manifest and no scheduler.

The image is one EL2 program, one trivial EL1 payload and one deliberate
fault, shared between both targets so that the same program answers the same
questions on a model and on a part -- which turned out to matter more than
expected.

WHAT IT SETTLED

HPRENR really is wider than 16 bits. The TRM's prose says regions 0 to 15 and
its own bit tables say [19:0] for 20 regions; the tables are right. The
S32Z280 implements exactly [19:0], and -- proven functionally rather than by
reading the register back -- a bit above 15 genuinely disables its region: an
EL1 read of the granule region 16 covers succeeds with the bit set and takes a
stage-2 fault with it clear. A register that accepted the bit and ignored it
would have passed a read-back test and made a one-write partition switch
silently leave the outgoing partition's regions live.

Regions above 15 are directly addressable at opc1 = 5, confirmed on both parts
by programming region 16 directly and reading it back through the selection
register. So the EL2 MPU avoids the 542-604 cycle penalty measured at EL1
during the Cortex-R52 Modules port work for reaching a high region.

HPFAR does not mean the same thing on the two targets. The TRM describes it
two ways in one section -- FIPA[39:12] at HPFAR[31:4] in its figure, "bits
[31:4] of the faulting address" in the table beside it -- and the FVP
implements the first while the S32Z280 implements the second. The two differ
by a factor of 256 and each is plausible alone, so there is no portable decode
of HPFAR here. ZoneX uses HDFAR, which carries the full faulting address on
both. The fault report prints HPFAR raw, computes both readings and names
which one the target implements, because the divergence is a fact the next
person needs.

The FVP implements an EL2 MPU and reports 32 regions at both stages, which is
not an architecturally legal Cortex-R52 value -- so a green model run still
proves nothing about a real part's budget. The image says so in its own output.

THE ONE THAT CHANGED THE DESIGN

HSCTLR.BR grants permission, not attributes, and on the S32Z280 that bites
before the hypervisor can print anything. Reached through the background map,
LINFlexD_9 at 0x42980000 is Normal Write-Through memory, and Normal memory
reorders and gathers even with caches off -- which corrupts a polled UART's
register protocol. The first version printed its whole identity block as
legible-but-wrong text, looking exactly like a marginal baud rate.

So enabling protection is two steps, not one: the hypervisor's own MMIO
regions and HSCTLR.M come up before the console, and HCR.VM only after the
partition regions exist. zx_stage2_enable refuses to set HCR.VM while
HSCTLR.M is clear, because that combination would run a guest unprotected
while every check appeared to pass.

HOW A FAULT COMES BACK

zx_el2_run_payload saves EL2's context, ERETs to EL1, and does not return
through that ERET; the trap handler resumes the saved context with a result
code, so a guest fault reaches the hypervisor as a value rather than as a jump
into a handler with no context to decide policy in. That is also what lets one
run demonstrate several violations instead of only the last one.

A fault taken FROM Hyp mode is the exception and reports differently: its own
vector, EC 0x25 rather than 0x24, its own message and its own exit code. It
has been provoked deliberately on both targets, because a hypervisor bug
reported as a partition being stopped at its boundary is a run that passes
while proving nothing.

EVIDENCE THAT IT CAN FAIL

Three builds of the same image, two of which must fail and are registered
WILL_FAIL in CTest: the deliberate violation aimed at an address the payload
IS granted, and the image told it needs more regions than exist. Both have
been seen to fail on both targets, naming what went wrong. The deliberate-EL2-
fault build is not registered, because its expected outcome is a failure
report and a suite taught to accept that would accept it everywhere.

The host suite covers the fault decoder to 100% of its lines, asserting on
real syndromes captured from both targets -- including the HPFAR divergence,
so a later simplification back to one reading breaks the build.

Also fixed along the way: the two example options are now mutually exclusive,
because the board's reset state and console are properties of the port library
and a PRIVATE definition on an image never reached it -- which had produced an
S32Z280 image with an A32 entry point and a semihosting console that built,
linked and would have printed nothing. The gcc and clang lanes now build the
images rather than only the libraries, since images are EXCLUDE_FROM_ALL and a
compile error in one was slipping past both.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The two negative FVP tests could pass for the wrong reason.  CTest's
WILL_FAIL inverts the exit status and nothing else, so it could not
distinguish the outcome the suite wants -- the image ran and reported its
own failure -- from the ones it does not: the image was never built, the
model would not start, or the run hung.  All of those exit non-zero, and
WILL_FAIL called all of them passes.

The images are EXCLUDE_FROM_ALL, so this was reachable from a clean tree:
with them unbuilt, `ninja && ctest` reported zx-fvp-probe-negative and
zx-fvp-probe-starved as Passed on "application file not found" -- two
green lines asserting nothing whatever about stage 2.  A negative test
that passes because nothing ran is worse than no test, because it is
counted.  CI was not affected; it names the images as build targets
before running the suite.

The runner now takes --expect {pass,fail} and judges the verdict itself,
and checks the image exists before launching the model.  The property
WILL_FAIL was chosen for is kept: a negative build that starts PASSING,
meaning the violation stopped being detected, still fails the suite.

Verified in all three directions: with the images absent all three tests
fail; with them built all three pass; and pointing --expect fail at the
positive image fails by name.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Several comments cited a numbered section of a planning document that is
not in this repository.  The facts were fine; the citations were useless
to anyone reading the repository, since that document is not here to
read, and they date badly besides.  Each one is reworded to keep the
fact and drop the reference.

The check is the actual fix.  These went in one at a time while the work
was fresh, and a manual sweep is what let them through -- it looked for
absolute paths but not for a citation, and would have missed the
hyphenated spelling even if it had.  scripts/check_references.sh
now rejects local absolute paths, numbered references to documents that
are not present, and a tracked agent-instruction file, with the same
skip-marker convention as the terminology check.  It found the hyphenated
one immediately.

It runs in CI beside scripts/check_terminology.sh, in the same job and
with if:!cancelled() so that one push reports both checks' findings
rather than one per re-run.  Deliberately narrow: a check that cries wolf
gets bypassed.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The manifest is the user-facing contract of a ZoneX system, so this
change is mostly about which vocabulary it is written in.

ZoneX adopts the suite's own integer type names -- the eight typedefs
every ThreadX port carries -- rather than spelling the manifest in
<stdint.h> terms.  The suite-wide C17 upgrade settles the question: it
changes no struct layout, no calling convention and no type name, and it
regression-tests that a C99 application still compiles against the new
headers, which makes those names load-bearing for that promise and
therefore permanent.  A manifest written in uint8_t would make ZoneX the
one component of the suite whose contract used a different vocabulary,
for no gain.  Addresses stay zx_addr_t: ULONG is not pointer-width on
every ABI the suite targets.  ZoneX keeps its own copy of the typedefs
rather than reaching for tx_port.h, because a manifest has to be readable
with no ThreadX checkout in sight; the copy is deliberately unguarded so
that a divergence is a hard error at the include rather than a quiet one.

The attribute fields are UCHAR and not an enum, and that was measured
rather than assumed: a four-value enum is 1 byte under arm-none-eabi-gcc,
which uses AAPCS short enums, and 4 bytes under ATfE clang and on the
host.  ZoneX builds with all three, so an enum field would give the
manifest a different layout in each lane.  The descriptor's layout is now
asserted -- the four attribute bytes consecutive in every lane, and the
whole descriptor pinned to 12 bytes on a 32-bit port, so that a change in
either target toolchain fails the build instead of producing two images
that disagree.

The shared-memory type carries the reasoning it needs.  Phase 0 shares
one read-only granule so the demonstrator has a positive channel to show
rather than only proving isolation by what faults, and a single region
cannot express it: stage-2 AP belongs to a region, not to a partition, so
the publisher covers the granule writably and each reader read-only.
Those ranges coincide, which is safe only because one partition runs at a
time -- exactly the kind of reasoning that must be written down.  What it
weakens and what it does not is stated on the type.

The region descriptor and the AP/SH/XN encodings move out of the port
header into the manifest, because the host-side validator has to build
and check the same objects with no Cortex-R52 header in reach.  Nothing
in assembly used them, which is what made the move free.  The port keeps
the field positions they shift into and the HMAIR byte layout, which is
this port's choice rather than an architectural fact.

Also carries three of the reference rewordings from the previous commit,
in the three files that changed for both reasons.

Verified: both repository checks, the host suite, and the FVP suite under
GCC and ATfE clang, plus S32Z280 builds under both toolchains.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
D19 records why the manifest uses the suite's own type names with
zx_addr_t for addresses, including the measurement that settled the
attribute fields: a four-value enum is 1 byte under arm-none-eabi-gcc
and 4 under ATfE clang and host gcc, so an enum in a published struct
would give each build lane a different layout.

D12 is corrected rather than extended.  It claimed ZoneX "is the
reference implementation of the suite-wide C17/CMake plan rather than an
exception to it."  The plan makes no such claim -- it states it is
independent of this roadmap and asks only that ZoneX start directly in
C17 with the capability macros -- and ZoneX is in fact an exception on
one point: the plan proposes extensions off in the CI preset and on in
the default build, while ZoneX has them off in the default build.  That
is stricter and worth keeping, but it is a deviation, and the entry now
says so.  The claim mattered: it was load-bearing in an argument for
spelling the manifest in <stdint.h> terms before anyone checked it.

CONTRIBUTING.md and the pull request template gain the new reference
check, with a note on what a comment may cite.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Almost nothing that can be wrong with a manifest faults, which is the
reason this function exists.  An under-aligned base does not abort: its
low bits land on the SH, AP and XN fields and silently change the
region's attributes.  An unmasked limit lands on AttrIndx and selects an
unwritten MAIR byte, which reads as Device-nGnRnE -- memory that works,
slowly, and passes a careless test.  Two enabled regions covering one
address is CONSTRAINED UNPREDICTABLE.  A wrong entry point by four bytes
costs a hardware session to find.  The defects worth catching are exactly
the ones with no symptom, so they have to be caught before the regions
are programmed rather than debugged after.

One error code per rule, and the fault record carries TWO locations
rather than one: the overlap rules have two offenders, and naming only
one leaves the reader hunting for the other.  Unused index fields hold an
explicit sentinel, because zero is a valid partition and region index and
a zeroed field would be indistinguishable from a real one.

The validator is pure and takes the facts it cannot know -- the
hypervisor's MMIO regions, which MAIR bytes were actually written, the
region budget -- as an argument.  That is what lets one function serve
both callers: the host suite hands it values and gets coverage, the boot
path hands it the real HMPUIR.

The cross-partition overlap rule is waived only for a pair of regions
that BOTH exactly cover the same declared shared range.  Exact, because
waiving it for anything merely touching a declared range would turn one
published granule into a licence to overlap around it.  The shared
declaration and the regions are also checked against each other: a range
declared read-only whose reader was given a writable region would be a
two-way channel described as one-way, and both partitions would simply
work.

150 checks.  Every error code has a case, every case asserts the reported
indices as well as the status, and the arithmetic has its own: adjacent
regions that touch are legal and must not be rejected, a one-granule
region is the smallest legal one, and ranges at the top of the address
space must not wrap -- which is why the overlap test is two comparisons
and computes nothing.  100% line and branch coverage on the validator.

Two harness fixes came out of getting there:

The coverage report was stale.  gcov ACCUMULATES into .gcda files, and
nothing deleted them, so a second run over the same build tree reported
the union of both and coverage could only ever appear to rise.  Measured:
with one rule's tests disabled the report still said 100% and the new
floor still passed.  CI never saw it because every run is a fresh
checkout -- the machine getting the wrong answer was the contributor's,
asking whether their own change had dropped coverage.  The counters are
now deleted before the instrumented run.

And the floor itself is enforced, scoped to the validator rather than set
repository-wide: much of core/ is only true on hardware and the host
suite cannot reach it, while a floor pinned to whatever the tree measures
today would assert nothing about the code.  Verified to fail, at 97.5%
with a rule's tests removed, and to name the uncovered lines.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
THE READBACK.  At stage 2 the alignment bugs fail in the attributes and
not in the address: a base one byte past a granule boundary does not
fault, because its low bits ARE the SH, AP and XN fields.  The region is
programmed successfully with permissions nobody asked for, and runs, and
nothing ever reports it.  zx_stage2_region_readback decomposes a
programmed region back into the descriptor a manifest would have
declared, and zx_stage2_region_matches compares one against its intent.

The EN bit is deliberately not decomposed: a manifest declares what a
region IS, while whether it is enabled belongs to the running system.
Folding it in would make a region read back unequal to the one written
purely because a different partition is current.

THE SWITCH.  zx_stage2_enable_set replaces the whole HPRENR mask in one
write with ONE barrier pair, DSB before ISB.  That order is load-bearing:
issuing the ISB before the write has retired would let the following
fetch be permitted under a mask no longer in force, which is a partition
briefly running with its predecessor's memory.

The direct region encodings above index 15 are NOT generalised, and that
is a decision the measurement enables rather than an omission.  The
542-604 against 434-470 cycle figure only applies if regions are
reprogrammed per switch; with fixed blocks and mask switching they are
programmed once at boot, so unrolling 25 indices -- coprocessor register
numbers must be compile-time constants, so it means roughly a hundred
inline MCR/MRC statements -- would speed up an operation that happens
once.  The barrier policy for the switch path now lives in one function,
because "how long does a partition switch take" needs an answer that is
not assembled from three files.

THE PLANNER.  zx_mm_plan turns a manifest into a region-index layout and
a set of HPRENR images, and touches no hardware: the caller walks the
plan and does the writing.  The split is what makes the interesting part
-- which index each region lands on, which bits each mask carries,
whether the total fits the part -- reachable from a workstation.  Passing
the programmer in as a function pointer would have kept one function at
the cost of an indirect call on the switch path.

Fixed blocks rather than packing, so a switch is one register write for
any manifest instead of a loop whose length depends on the incoming
partition.  The hypervisor's MMIO occupies the lowest indices and is
enabled in every mask, because a partition switch must not blind the
hypervisor's own console.

The layout is printed once at boot.  Every later fault report names a
region INDEX, and the same index is a different window on the two targets
-- the model spends none of them on hypervisor MMIO and silicon spends
two -- so without the printout a diagnostic cannot be mapped back to the
manifest.

Tests assert the exact mask patterns and then the property behind them:
no two partitions' masks share a bit outside the always-on block, and
every mask carries that block.  The patterns catch an arithmetic slip;
the property catches a redesign that is internally consistent and wrong.
On hardware the symptom of a wrong mask is that everything works.

The coverage floor now covers the planner as well as the validator, both
at 100% line and branch, and was re-verified to fail when a rule's tests
are removed.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
TWO PARTITIONS, BOTH DIRECTIONS.  A run in which partition A reaches
its own memory and faults on B's is equally consistent with B's memory
being mapped by nobody, which is not isolation but an empty address
space with one tenant.  So both directions run: B then executes, in its
own memory, and faults on A's.  The same address is reachable in one
region set and unreachable in the other, which is what a partition
switch is.

Measured on the model: cross-partition probes fault with DFSC 0x04, no
enabled region covering the address, and the refused write to the shared
granule faults with DFSC 0x0C -- a region matched and its AP said no.
Distinguishing those two was the point of checking the status code
rather than the fault alone: "read-only" and "not mapped here" are
different claims and only one of them describes a shared granule.

The adjacent granule carries more weight than the neighbour does.
Faulting on the other partition's data proves the sets differ; two sets
a kilobyte apart would prove that too.  The ungranted granule sits
immediately after partition A's data window, by linker-script
construction and with an ASSERT to keep it there, so touching it is what
says the LIMIT is exact.

A RELOCATABLE GUEST BLOB.  Two partitions cannot run the same program in
different memory while its data addresses live in literal pools, so the
blob takes its data window base in a register and reaches everything at
a fixed offset from it.  Nothing to relocate: branches were already
PC-relative, a literal holding a constant survives the copy, and a
literal holding an ADDRESS is what the base pointer removes -- there is
not one in the section.  EL2 copies the blob into each code window,
which is the path a real guest image takes, so the manifest's image
bounds and the IMAGE_TOO_LARGE rule are load-bearing now rather than
hypothetical.

Entry points sit at declared offsets because the hypervisor computes
them as window_base + constant.  .org places them, and .org refuses to
move backwards, so a routine that outgrew its slot fails the assembly
instead of silently displacing the next entry point.  The obvious
alternative -- a .space guarded by ".if (. - blob_start) > offset" --
assembles under GNU as and is rejected by LLVM's, which will not treat a
label difference as absolute before layout.  ZoneX builds with both.

THREE NEGATIVE VERIFICATIONS, each its own image because they fail at
different points and one image would only show whichever came first:

  widened  a region limit one granule too generous, swallowing the hole.
           The manifest is VALID -- covering the hole breaks no rule --
           and only the runtime probe catches it, which is the clearest
           statement of why both layers exist.
  overlap  two partitions' data windows on one address.  Refused with
           PARTITION_OVERLAP naming both offenders, before a single
           region is programmed.
  badattr  a region naming an HMAIR index this image never wrote.
           Refused with ATTR_NOT_WRITTEN.  The hardware would not
           object: the region gets Device-nGnRnE and works, slowly.

THE PMU, so a switch can be measured rather than estimated.  The generic
timer is not usable -- CNTFRQ reads zero on both targets -- and cycles
are the unit a WCET argument is made in anyway.

PMCCFILTR is under CRn = c14, not c9 with the rest of the PMU, and it is
the register that decides whether Hyp mode is counted at all.  Writing
it at c9, c14, 7 is UNDEFINED: it took an undefined-instruction
exception at EL2 on the first PMU write, reported through the vector
because +0x04 carries no syndrome.  Encoding corrected against TRM Table
3-15 and recorded where the next reader will look.  The counter is also
asked whether it is advancing before anything is timed, because a
measurement from a stopped counter reports zero cycles for everything.

Verified: both repository checks, the host suite, the FVP suite at 6/6
under GCC and ATfE clang, and S32Z280 builds under both toolchains.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The reference sheet gains the PMU cycle counter, with the encoding trap
that cost a run: PMCCFILTR is under CRn = c14, not c9 with the rest of
the PMU, and writing it at the plausible c9, c14, 7 is UNDEFINED.  It
also decides whether Hyp mode is counted at all -- the TRM defers the
bit layout to the architecture, and without bit 27 the counter reads
zero forever, which is indistinguishable from a part that has no PMU.

The hypervisor-to-guest mailbox is documented as a convention rather
than left to be read out of the example: it exists because a guest
excursion carries one argument while a probe needs both a target and
somewhere to report, and it is free because stage-2 AP cannot deny EL2.
The same property that stops AP from isolating partitions is what lets
the hypervisor write into a partition's memory for nothing.

D4 is settled on mechanism.  The cost ratio that the preference rested
on is measured -- 13 cycles for a mask switch against 54 for one region
descriptor write -- and the mask's cost, unlike a block rewrite's, does
not grow with the incoming partition's region count, which is the
property a WCET argument cannot do without.  It also records why the
direct encodings above index 15 stay ungeneralised: they would speed up
an operation that happens once.

Both sets of figures are labelled as the model's.  A functional model
does not model timing, so the numbers to quote still have to come from
the part; the measurement path is in the image and runs on every target,
so that closes with a board session rather than with new code.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Everything before this ran a payload written to be run: a few dozen
instructions with no kernel, no stack use worth the name and no vectors
of its own.  This loads a whole ThreadX, which is the difference between
"stage 2 works" and "a hypervisor works".

A REAL, UNMODIFIED KERNEL.  The sources and the Cortex-R52 port are
built exactly as they are standalone.  What differs is one build option
the port has carried since it was written -- for the case its own
comment describes, "a vendor EL2 monitor has already dropped privilege
to EL1" -- a linker script describing one partition window, and a board
file with no devices in it.  On the model that is the whole story: the
guest builds from unmodified upstream sources, which is why CI can pin a
public commit and boot a kernel on every pull request.

WHAT THE GUEST PROVES, and each milestone is one the previous cannot
fake: it reaches bsp_main, so the ERET landed; tx_application_define
runs, so the kernel initialised inside a confined window; two threads
take slices, so the ported context switch works underneath stage 2; a
queue carries every message CHECKED BY VALUE AND IN ORDER, because four
deliveries of the wrong thing would satisfy a count; and a semaphore
grants once and refuses the second get.  The hypervisor reads all of it
out of the guest's own memory afterwards rather than trusting anything
the guest printed.

THE READBACK IS SEALED, which was the open question and is now a
structure rather than a word.  A single progress word cannot tell three
failures apart: a window nobody wrote reads as zero and zero is
plausible; a guest torn off mid-update leaves words individually valid
and jointly nonsense; and a report read from the wrong partition's
window looks exactly like a report.  So the guest writes every field,
then a sequence, then a checksum with the HYPERVISOR'S OWN SENTINEL
folded in -- and a report can be attributed rather than merely found.

AN HVC CONSOLE, settling the open half of D8.  One hypercall per
character, and the hypervisor tags each line with the partition it
SCHEDULED, so a guest cannot claim to be another one.  That last part is
free and it is what a safety reviewer asks about.  The tagging rules are
text, so they are asserted against a capture buffer in the host suite:
one tag per line and no more, no dangling tag after a final newline, a
partial line closed before the hypervisor speaks, and a guest's forged
tag appearing as ordinary text inside a correctly attributed line.

THE TWO STAGES ARE DISTINGUISHABLE, and by construction rather than by
decode.  A stage-2 violation is taken to EL2 and reported there; a
stage-1 fault is taken to EL1 and reaches the hypervisor only because
the guest's own vector records it and hands control back.  Different
code, different privilege levels.  Both are demonstrated, and the
demonstration of the first needed a fact worth writing down: both stages
are checked and the stricter wins, so an address outside the guest's own
EL1 regions is denied by STAGE 1 first.  A guest whose own MPU stops at
its window boundary cannot show that stage 2 stops anything.  It has to
grant itself the granule -- its own MPU then claiming memory the
manifest never gave it -- and stage 2 refuses anyway.

ONE GUEST IMAGE SERVES EVERY CASE.  The hypervisor selects between a
stage-2 violation, a stage-1 fault and an access that is genuinely
permitted by writing an ADDRESS into the mailbox, not by building a
different guest.  A binary per case is a set of binaries that can drift
apart, and the one demonstrating isolation would stop being the one
demonstrating that the kernel runs.  The permitted-address build must
report FAILED, and its failing is what makes the other two evidence.

A GUEST DECLARES THE WINDOW IT WAS BUILT FOR, because a guest linked for
the wrong one STARTS.  Its entry is a PC-relative branch that survives
being copied anywhere; every other absolute address in it is baked in.
So the failure is not a guest that will not run -- it is one that boots,
schedules, and then faults at an address that looks perfectly reasonable
in the report, with nothing anywhere saying the image was built for
somewhere else.  Three words in the image and one comparison in the
loader turn that into a refusal.  The magic among them earns its four
bytes twice: an .incbin whose file is missing, and a linker pattern that
matches nothing, both produce an EMPTY section rather than an error.

The loader splits the way the region planner already does: core/ decides
where the image goes and holds the state machine, and neither copies a
byte nor touches a register, so all of it is reachable from a
workstation.  It joins the coverage floor at 100% on lines and branches,
along with the guest console.  The manifest validator gains a rule for
an image of zero length, and its size rule is tightened to measure the
window the ENTRY is in rather than any executable region -- the weaker
form passes a manifest whose copy then overruns into a neighbour.

MEASURED ON SILICON, and two corrections were needed before the numbers
meant anything.  Stage 2 costs a warmed guest nothing resolvable: 27,052
cycles against 27,154 with HCR.VM clear, agreeing to 0.4%.  Getting
there needed the console suppressed -- one trap per character is
5,051,788 cycles against 27,052, so a loud run can only bound the cost
from above -- and a warm-up excursion discarded, because measuring only
two passes made stage 2 look 28% FASTER.  A hypervisor does not give
cycles back; the pair differed in ORDER as well as in HCR.VM, and on a
real core the first execution of anything is not the second.

D4's carried figure also comes off the part: 235 cycles for the HPRENR
mask switch against 472 for one region descriptor write.  The decision
is unchanged and its margin is not -- the model's ratio was 4.2 and
silicon's is 2.0, so a per-region cost extrapolated from the model would
have been optimistic by half.  What the design actually rests on is
untouched: the mask does not grow with the incoming partition's region
count.

Green on both targets: ten FVP images through CTest, six host suites,
and all four one-partition images run on a S32Z280-594EVB with the
negative one reporting its expected failure.  Both toolchains.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The cooperative guest proved a real ThreadX kernel BOOTS and SCHEDULES
inside a stage-2 window.  A cooperative kernel is a kernel with the
interesting half removed: nothing takes the core from a thread, so
nothing can go wrong in the place a partitioning hypervisor most needs
nothing to go wrong.  This adds the interrupt.

WHO OWNS THE GIC, which is the decision the rest follows from.

ZoneX owns every byte of memory-mapped GIC state and a partition gets
its own CPU interface, which on this part is system registers.  The
argument is not "shared things belong to EL2", which is true and weak.
It is that the redistributor's SGI frame holds the enable bits for all
thirty-two SGIs and PPIs of this core -- INCLUDING PPI 26, the
hypervisor's own timer, which is what will end a partition's window.  A
partition able to write that frame could clear that bit and never be
descheduled again, and nothing about that is a memory-isolation failure,
so no region set would show it.

Doing it this way costs the partition nothing: its manifest is
unchanged and it is granted no device region of any kind.  The
alternative -- mapping the redistributor into the partition so it could
enable its own PPI -- would have cost two extra EL2 regions on silicon,
because PMSAv8-R has no region priority and the window has to be split
three ways, AND opened the hole above.  Recorded as D24.

HCR.IMO STAYS CLEAR, so the timer PPI is a physical interrupt taken
straight to EL1: no injection, no List Register, no EL2 work per tick.
The cost is written down because it is the next phase's whole problem --
with IMO clear the hypervisor cannot take an interrupt of its own while
a partition runs either, so a tick that ENDS a window needs IMO set, and
with IMO set every guest interrupt has to be injected.  That is a change
to the hypervisor and to no guest, which is why this shape is worth
having first.

ONE GUEST IMAGE, and whether it has a clock is a word in the mailbox
rather than a build option.  The run that demonstrates isolation and the
run that demonstrates preemption are the same bytes, so they cannot
drift into two binaries where only one is the one being quoted.  The
guest is still built from unmodified port sources; the only change is
that it now uses the port's own TX_R52_USE_THREADX_IRQ, whose EL1 IRQ
vector body it reaches with one branch out of its own vector table --
so the interrupt entry sequence is the port's, unmodified, and VBAR
stays the guest's.

WHAT IS PROVED, on both targets: a thread that never yields is
DISPLACED, two equal-priority threads that never yield are TIME SLICED,
the partition receives INTID 27 and no other, and its clock does not
advance while it is not running.  The last is CNTVOFF, and it is exact:
on the S32Z280 the offset was credited with 1,969,637 counts against an
interval of 1,969,637.

TWO MEASUREMENT MISTAKES ARE IN THE HISTORY BECAUSE BOTH WERE MADE.

The gap the freeze is measured across was first taken over a block of
console output, on the grounds that a polled UART is millions of cycles.
It is -- on silicon.  On a model whose console is semihosting it costs
no simulated time at all, so the counter advanced by ZERO and the check
was green, and so was the build that deliberately breaks the freeze.
The gap is now a deliberate dwell, and the image asserts that a real
interval happened before it asserts anything about it.

Then the check was built on counter reads taken from outside the
mechanism, and reported the partition's clock advancing by 64 counts on
a run where the freeze was perfect: on the S32Z280 a CNTPCT read crosses
into an 8 MHz clock domain, so the handful of reads between the CNTVOFF
write and the check cost 11.6 microseconds of real time.  It was
measuring the cost of measuring.  It now compares only instants the
mechanism itself took, against the offset read back out of the register,
so every term is a hardware read and a CNTVOFF write that had not landed
still fails it.

TWO NEGATIVE BUILDS, breaking different halves and each failing exactly
one check on both targets.  One leaves the partition's PPI disabled --
which is the isolation claim showing up as a test result, since the
guest cannot enable it itself -- and one does not give the partition
back the time it spent descheduled.  The second keeps the bookkeeping
and drops only the credit, deliberately: skipping the resume outright
left the recorded instants stale and its observable check then PASSED,
comparing a real interval against the time since boot.

The loader is now shared rather than copied.  Two images launch the same
guest and they differ in what the hypervisor configures and in what they
claim, not in how a guest is loaded -- and the mailbox layout, the
header check and the seal are a contract with the guest image, which
cannot have two implementations.  The mailbox grew to two granules for
what a preemptive guest has to say, which moves the entry branch to
+0x80 and costs nothing at stage 2.

FOUR THINGS A REVIEW OF THIS CHANGE FOUND, all of them real.

GICR_CTLR.RWP and GICD_CTLR.RWP are now polled.  A DSB says a write has
left the core and says nothing about whether the GIC has finished acting
on it, and bring-up disables all thirty-two of this core's SGIs and PPIs
and then enables one of them in the same frame a few instructions later.
An in-flight disable retiring after that enable would undo it, leaving a
partition arming a timer whose interrupt was disabled -- a guest that
never ticks, on some boots.

The distributor's group enables are now CLEARED before ARE is set and
restored afterwards.  Changing ARE while the GIC is enabled is
UNPREDICTABLE, so setting ARE first is correct only if the enables were
already clear -- a claim about reset state, which this file argues
against making everywhere else and then made here anyway.

The guest blob's minimum-size ASSERT was still 0x50 after the header
moved to 0x8C, so a blob of 0x51 to 0x8F bytes linked cleanly and the
magic check read past the end of the section -- the out-of-bounds read
the assert exists to prevent.

And the build that probes an address it IS granted was aimed at mailbox
offset zero, which is ZX_GD_PROGRESS: the probe's sentinel was ORed into
the progress word and the seal then certified it.  Harmless noise until
this change gave two of those bit positions meaning, at which point a
cooperative build with no clock reported PREEMPTED and NO_CLOCK both.
The probe now has a word of its own that nothing reads.

Green on the Armv8-R AEM FVP under both toolchains, and logged ALL
CHECKS PASSED on an S32Z280-594EVB, where two identical quiet
excursions differed by 90 cycles out of 1,952,856.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The gcc and clang check workflows exist to hold this project to its
warning set, and they were not looking at the ZoneX code compiled into a
ThreadX image: the guest support and both partition images, several
thousand lines.  Neither workflow set ZX_THREADX_SOURCE_DIR, so the
example CMakeLists skipped the guest images -- correctly, and with a
message, which is right for a contributor who has no kernel sources to
hand and wrong for the lane whose whole job is strictness.

TWO LAYERS HAD TO GIVE WAY, and the second is the one worth reading.

The first is a ThreadX checkout in both workflows, pinned to the commit
zx_fvp.yml already pins, and the variable passed through to CMake.

The second is that fixing only the first would have changed nothing.
The guest is an ExternalProject -- a separate CMake invocation with
ThreadX's own toolchain file -- and an ExternalProject inherits nothing
that is not listed in its CMAKE_ARGS.  CMAKE_COMPILE_WARNING_AS_ERROR
stops at that boundary, so the guest images would have built in the
strict lanes with their warnings merely printed.

FORWARDING IT WHOLESALE WOULD HAVE BEEN WORSE THAN NOT FIXING IT.  That
sub-build compiles ThreadX's kernel as well as ZoneX's guest support,
and ZoneX does not get to decide how the kernel is compiled -- which is
the argument that makes the guest a separate project in the first place.
So the flags are applied PER SOURCE FILE, to the three files that are
ZoneX's, and to nothing else.  The two files the executable takes from
ports/cortex_r52 keep the port's settings, which matters more than it
looks: with -Werror a target-wide setting could fail this build on a
warning ZoneX neither owns nor can fix here.

The list itself comes from cmake/zx_warnings.cmake, which now publishes
ZX_WARNING_FLAGS as a variable alongside the interface library it
already built.  The guest project cannot see the hypervisor's targets,
and restating eleven flags over there would have been a second source of
truth that agrees until the day it does not.  -Wlogical-op is selected
by compiler at both ends for the same reason it always was: ATfE clang
rejects the GNU spelling outright, which is noise until warnings are
errors and a failed build afterwards.

ThreadX's own build sets no warning flags at all, so before this those
three files were compiled with NONE.  The lane found one thing
immediately, which is the sort of thing it is for: bsp_main was defined
with no prototype -- a MISRA Rule 8.4 finding, and a function whose
signature nothing was checking.  The four entry points the port calls
are now declared in zx_guest_bsp.h rather than forward-declared where
they happen to be defined.  Not by including the port's board.h, which
declares all four: the two boards' copies of that header differ, and the
files implementing these are shared between the boards.

Verified by running what the workflows run: all three gcc configurations
and both clang configurations configure, build and link every image with
warnings as errors and the guest images present.  The FVP suite is
unchanged at 13 of 13.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…ction

D24 was written when the single partition was first given a clock, and
its closing argument was wrong in a way that made the next step look far
larger than it is.  It reasoned that because HCR.IMO is clear, the
hypervisor cannot take an interrupt of its own while a partition runs,
and concluded that a tick which ENDS a partition's window therefore
needs IMO set -- and that with IMO set every guest interrupt would have
to be injected through a List Register.

ROUTING IS BY EXCEPTION TYPE, NOT BY INTID, which is the fact the
argument missed.  HCR.FMO sends physical FIQ to EL2 while HCR.IMO, left
clear, leaves IRQ with EL1.  So the hypervisor's own timer goes in GROUP
0 -- which is what the GIC delivers as FIQ -- and arrives at EL2 while
every partition interrupt stays Group 1, stays an IRQ, and is still
delivered straight to EL1.  No injection, no List Register, and no
change to any guest.

And it is BETTER than injection rather than merely cheaper.  With FMO
set, PSTATE.F is ignored at EL0 and EL1, so a partition cannot mask the
interrupt that ends its own window.  A tick delivered as an IRQ to EL1
could be deferred by any guest that disabled interrupts, which is
precisely the property time partitioning must not concede.

The guest half is already in place and was already right: a partition is
granted no Group 0 interrupt and never enables ICC_IGRPEN0, so nothing
can deliver an FIQ to it.  Only the prose drew the wrong conclusion from
what the code does.  What is missing is all at EL2 -- FMO, PPI 26 in
Group 0, and a body on the FIQ vector -- and none of it has been run.

Injection through the List Registers goes back to where it belongs: a
later phase, for making interrupt latency a hypervisor-controlled and
WCET-bounded quantity, rather than a prerequisite for a partition tick.

Comments only; no code changed and no behaviour with it.  The three
files that repeated the claim -- zx_gic.c, zx_timer.c and the preemptive
example's header -- are corrected with it, because a wrong reason
repeated in four places is four things to re-derive.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The workflows pin ThreadX to a commit, and that pin went stale the moment
the S32Z280 boot-at-EL1 bracket was upstreamed.  The pin still named the
dev tip from BEFORE that merge, so every guest image CI built for the
S32Z280 was built against a port that had never heard of
TX_R52_BOOT_AT_EL1.

A DEFINITION NAMING AN OPTION THE SOURCE DOES NOT TEST IS NOT AN ERROR.
It is inert.  So the build did not fail -- it produced a guest containing
the port's entire EL2 reset path, ERET included, which drops to EL1 from
EL1 and dies on its first instructions.  Confirmed by building one: the
guest from the stale pin carries an `eret` and the correct guest carries
none, and both link without a single warning.

That is the shape of failure this suite exists to refuse.  Nothing
executes a guest on a hosted runner, so the only symptom was an
instruction in a binary nobody ran, and the lane reported success.  The
warnings-as-errors lane added for the guest support made it worse rather
than better: it started building the S32Z280 guest for the first time,
against that pin, and called it green.

TWO FIXES, BECAUSE THE PIN WILL GO STALE AGAIN.

The pin is bumped to the dev commit that carries the bracket, in all
three workflows.  That is the immediate fix and it has the lifespan of
the next upstream option a guest depends on.

The lasting one is a guard in the guest's own build: it reads the port's
entry.S and REFUSES if TX_R52_BOOT_AT_EL1 does not appear in it, naming
what would otherwise happen.  Grepping the source is crude, and it is the
only check available -- the guest is a separate CMake project, it cannot
ask the port which options it supports, and there is no version to
compare.  Crude and loud beats invisible.

Verified both directions: against a checkout without the bracket the
build now FAILS with that message, and against one with it the FVP suite
is 13 of 13 and both boards build under both toolchains.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Carried out of the single-partition work as findings, all outside its scope
and all inside this one.

THE VALIDATOR AND THE LOADER DISAGREED ABOUT ONE BYTE.  zx_manifest_verify
compared an image's length against (limit - base) while the limit is
INCLUSIVE, so an image that exactly filled its window was refused at boot
with a message saying it was too large for a window it fits.
zx_partition_prepare has always had the rule right.  The two now read
identically, and the host suite pins the boundary from both sides -- a
one-sided test cannot tell a rule that is off by one from a rule that is
right.  The loader's own boundary test already existed, which is exactly why
nothing caught the disagreement.

THE REGION PLANNER BUDGETED AGAINST THE WRONG REGISTER.  It checked HMPUIR,
which says how many region DESCRIPTORS a part has, and ignored the measured
width of HPRENR, which says how many of them a one-write partition switch can
turn OFF.  The Cortex-R52 TRM disagrees with itself about that width and both
ZoneX targets came out twenty bits wide, so it does not bite today; on a part
where the two differ, a partition seated past the mask would have its
descriptor programmed with its own enable bit set and left there, and the
outgoing partition's window would stay live underneath the incoming one.  No
fault, no diagnostic, and an isolation claim that is simply untrue.
zx_mm_plan now takes both budgets and refuses with a code of its own.

A NEGATIVE BUILD ANNOUNCED THE VERDICT IT WAS SUPPOSED TO EARN.  The probe's
negative banner contained the literal fail mark, and the fail mark is what
proves a negative run REPORTED its failure rather than crashing -- so every
negative run satisfied that condition in its first few lines and a hang
afterwards would have been judged a correct result.  The banner is reworded
and the runner now matches a verdict at the START OF A LINE, which closes it
at the reader rather than at the writer.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
core/src/zx_schedule.c was the last empty file in the repository.  It now
holds the window table, the round-robin advance and the arithmetic that says
where each boundary falls on the physical counter.

EVERY BOUNDARY IS AN ABSOLUTE COUNT FROM ONE EPOCH, never a countdown
re-armed at each tick.  Re-arming a relative interval inside the handler adds
the handler's own latency to every window, so a frame declared as 10 ms
becomes 10 ms plus the switch -- cumulatively, for the life of the run.  The
schedule still looks fixed and the windows stay in the right proportion; the
frame simply stops being the length it was declared to be.  It is the most
common way a deterministic frame stops being one and it is invisible in any
run short enough to read.

WHICH IS WHY THIS FILE JOINS THE 100% LINE-AND-BRANCH FLOOR, and the decision
was made rather than defaulted either way.  Catching drift needs ten thousand
frames, which is a millisecond on a workstation and two minutes on the
S32Z280 -- and the central host case asserts the ten-thousandth boundary is
EXACTLY the epoch plus the tick count times the tick length.  The arithmetic
is also 64-bit against a counter that has been running since reset, so an
epoch just below 0xFFFFFFFF is a fixture here and nine minutes of waiting on
a bench.

The rest is deliberately absent.  One window per partition, in manifest
order, lengths fixed at build time; no priorities, no admission control, no
yielding into a neighbour's window.  An idle partition BURNS its window,
which is not a limitation to be fixed later but what a static frame means:
handing the remainder to the next partition would make one partition's start
time depend on another's behaviour, and the independence of those two things
is the entire purchase.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The partition switch, and the routing that makes one possible.

HCR.FMO SET, IMO AND AMO CLEAR.  The hypervisor's own timer goes in GIC
Group 0, which the GIC delivers as an FIQ, so it arrives at EL2 -- while
every partition interrupt stays Group 1, stays an IRQ, and is still delivered
straight to EL1 with no injection and no List Register.  Routing is by
exception TYPE and not by INTID, which is what makes this two register writes
rather than an interrupt-virtualization layer.

It is better than injection rather than merely cheaper: with FMO set,
PSTATE.F is IGNORED at EL0 and EL1, so a partition cannot mask the interrupt
that ends its own window.

FMO IS BIT 3 AND AMO IS BIT 5, AND THIS PORT HAD THEM THE OTHER WAY ROUND
from its first commit -- the mnemonic order a reader reaches for is the
alphabetical one.  It survived because IMO is bit 4 either way and because
every use of the three until now was a single BIC of all three at once.  The
first image that SET one of them routed nothing: bit 5 is AMO, so physical
FIQ never reached EL2 and no window ever ended, while the comparator expired,
the GIC made the interrupt pending and every set-up check printed green.  A
check written as (HCR & ZX_HCR_FMO) could not have caught it; the positions
are now asserted and the image checks the BIT.

AND SETTING FMO MOVES THE GUEST'S ICC_PMR TO THE VIRTUAL INTERFACE.  TRM
9.3.5: an EL1 access to any register common to both interrupt groups is
redirected once FMO or IMO is set, and ICC_PMR is one of them.  Every guest
writes ICC_PMR = 0xFF at start-up; from that moment the write lands in
ICV_PMR while the physical mask -- which resets to zero, masking everything
-- is left closed.  A partition that was receiving its timer end to end stops
receiving anything, with no fault and no message.  The hypervisor now owns
the physical mask.  The same redirection closes D24's last gap: a partition
cannot reach the physical Group 0 enable either.

THE SWITCH IS FIFTEEN WORDS OF ASSEMBLY AND THE REST IS C.  Hyp mode banks
only SP, LR and SPSR, so r0-r12, ELR_hyp and SPSR_hyp are all that cannot
wait; a guest's banked registers, its EL1 system registers and its whole EL1
MPU are still in the machine and are reached from C.  That keeps the twenty-
region loop -- which measures at 85% of the switch on both targets -- eight
readable lines instead of a hundred and twenty coprocessor moves.

The FPU is DENIED to a time-partitioned system rather than saved, because
nothing saves the register bank and two guests would share it; the failure
mode is a wrong ANSWER and not a fault.  The traps bracket the frame exactly,
because with them set the S32Z280's debug probe cannot read the core's
registers at all.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The image this repository has been building to.  Two ThreadX kernels, each in
its own stage-2 window, time-sharing one logical core under a major frame
taken from the manifest: partition A holds seven ticks of every ten and
partition B three, for twenty frames.

MEASURED ON THE S32Z280-594EVB, twenty frames and thirty-nine boundaries,
with no missed deadline and no interrupt the hypervisor's own timer had not
raised:

  partition A   11,157,245 counts on the core, 139 of its own ticks
  partition B    4,822,000 counts on the core,  51 of its own ticks

A x 3 = 33,471,735 against B x 7 = 33,754,000, within 0.84% of exact.  That
equality IS the temporal claim rather than a symptom of it: a partition able
to see wall clock would be out by a factor of three, not by a percentage.
The 20,755 counts unaccounted for against the frame's 16,000,000 are the
thirty-nine switches, charged to neither partition.

The switch costs 5,630 / 5,684 / 5,944 cycles min / mean / max, and the
guest's own EL1 MPU is 85% of each direction on BOTH targets -- across a
32-region model and a 20-region part.  A partition switch is not expensive
because the hypervisor does much; its per-partition state is three register
writes.  It is expensive because a guest has a lot of registers.

FOUR BUILDS, AND WHICH ONES MUST PASS IS THE POINT.

  the frame          both partitions run to the limit, clocks in ratio
  hog                B masks IRQ and FIQ at EL1 and spins for ever.  It is
                     preempted anyway, and the masking costs it exactly its
                     OWN kernel's tick: its tick count froze at 4 while its
                     liveness counter reached 229,233.  A frame driven by an
                     interrupt taken at EL1 would HANG on this build.
  cross              B grants itself a granule inside A's window in its own
                     EL1 MPU and writes to it.  Stage 2 refuses, B is
                     stopped, and A runs to the end of the frame with its
                     schedule untouched -- which is the half of isolation a
                     single partition can never demonstrate.
  no_tick, overlap   must FAIL, and now REPORT their failure rather than
                     hanging: a hypervisor that cannot deliver its own tick
                     refuses to start a frame at all, for the same reason a
                     guest is never allowed to arm a timer against a stopped
                     counter.

A SECOND PARTITION WAS MEANT TO BE A DATA CHANGE AND IT WAS NOT.  It was
going to be a second copy of a two-hundred-line build file and a second copy
of a linker script, differing in one address each.  The guest project is now
configured once per partition from a template instead, which is the
generality that was missing rather than a special case for partition B.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
D25: how a partition's window ends.  D24's Design A, built, run on both
targets, and carrying what it cost that the design did not predict -- the
ICC_PMR redirection, the deliberate omission of HCR.AMO, and the FPU denied
rather than saved.

The reference sheet gains what no session should have to re-derive:

  HCR's routing bits are FMO[3], IMO[4], AMO[5] -- not the alphabetical order
  a reader reaches for, and this port had two of them swapped.  The three
  questions that localised it are written down with it, because a window that
  never ends looks identical whether the comparator did not expire, the GIC
  was never told, or the core never took the exception.

  CNTHP: TVAL, CTL and the 64-bit CVAL a frame is actually built on, with why
  the absolute form is right here and the relative form is right one level
  down inside a guest's own handler.

  Which CPU-interface registers an EL1 access is redirected for once FMO is
  set, from TRM 9.3.5, and why ICC_PMR being in the "common to both groups"
  row is the one that bites.

  That the floating-point traps block the S32Z280 debug probe's register
  read, measured by comparing an image that stops before them with one that
  stops after.

The state-a-switch-must-save table is now what a switch DOES rather than what
it will have to, with the measured cost of each group on both targets beside
it -- and one figure that disagrees with D4's by half on both targets, which
is recorded as unexplained rather than quietly replacing the published one.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two went in while the partition switch was being written, and both were in
CODE COMMENTS rather than in documents -- which is where nobody thinks to
look for them.  The existing check rejects a local path and a numbered step
and had nothing to say about either.

The planning documents such identifiers name live outside every repository,
so a comment citing one points a reader at something they cannot obtain and
never will.  The FACT is almost always worth keeping and is almost always
citable another way: those measurements were taken during a named piece of
work on a named board, so "measured on the S32Z280 during the Cortex-R52
Modules port work" carries everything the identifier did and survives the
reader.

Checked case-sensitively and separately from the rest, because folding it
into the case-insensitive pattern would make an ordinary lower-case word an
error.  Seen to fail before it was believed.

The pattern spells the shapes it rejects, because a denylist has to.  That is
the same bargain scripts/check_terminology.sh makes and states: one place
holds the spellings, so the list cannot drift away from what is enforced.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Every switch cost this suite publishes is in core CYCLES, which is the right
unit for a worst-case-execution-time argument and is not a self-contained
one.  Nobody could convert them: CNTFRQ reads zero out of reset and this part
reports its core clock nowhere, so "5,715 cycles" was a number no reader
could turn into a duration -- or argue with, which is not the same as one
they should accept.

MEASURED AGAINST THE SYSTEM COUNTER, whose frequency is the one in this
system that has actually been established -- three independent ways, during
the Cortex-R52 port work.  48,050,135 Hz on the S32Z280-594EVB, over one
millisecond of counter time.

AND THE ANSWER EXPLAINS ITSELF.  The S32Z27 reference manual gives FIRC as 48
MHz and says FIRC_CLK is the default clock for the entire system at power-up.
ZoneX configures no clock tree, so the core spends every run on the part's
backup oscillator.

THAT QUALIFIES EVERY CYCLE FIGURE, AND IN THE OPPOSITE DIRECTION FROM THE
OTHER CONDITIONS.  The EL2 caches are off and the image is built -Og, so a
warm, optimised switch can only be faster: both make the figure an
over-estimate.  A backup-clocked core makes it an UNDER-estimate -- at 48 MHz
the memory a switch touches is cheap in core cycles, and raising the core
clock without raising the memory's makes the same code cost more cycles, not
fewer.

So a switch figure from this bench is an over-estimate for two reasons and an
under-estimate for a third, on one board, and it is NOT a worst case in
either direction until it is taken again with the clock tree configured.
Listing only the conservative conditions and letting a reader conclude it was
an upper bound would have been the easy thing to do and the wrong one.

The image now prints all four conditions, the measured clock, and a
nanosecond conversion ABOVE its own numbers, together with the two things
deliberately excluded from them -- so a figure quoted out of a log carries
its conditions with it instead of leaving them in a document.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Its status section said there was no ThreadX guest, no partition manifest and
no scheduler.  All three exist, and two partitions now time-share a core
under a static frame on both targets, so the front page had drifted three
pieces of work behind the tree.

WHAT THE REWRITE IS CAREFUL ABOUT, because a front page is where a number
gets quoted from and the conditions do not travel with it:

  IT LEADS WITH MATURITY.  "This is not production software and is not close
  to it."  The heading keeps "under construction" and gains "demonstrator".
  The section below it already said an overclaimed demonstrator is worth less
  than an honest one; the top of the file now says the same thing first.

  IT SEPARATES MECHANISMS FROM MEASUREMENTS, and does not hedge both the same
  way.  A partition cannot reach its neighbour's memory, a window ends
  whether the partition agrees or not, and each clock advances only in its
  own windows -- none of those move when a number does, and burying them
  under the caveats the numbers need would underclaim the results that are
  settled.  An underclaimed demonstrator is worth less than an honest one
  too.

  IT MARKS WHICH NUMBER IS ROBUST.  The seven-to-three ratio is a ratio of
  two readings of the system counter, whose frequency was established three
  independent ways, so it cancels the core clock, the caches and the
  optimisation level outright.  The switch cost does not, and says so.

  IT COMMITS RATHER THAN CAVEATS.  Configuring the clock tree is named as a
  later phase and the switch figure is stated to be expected to change when
  it lands -- with why it will move in each direction, and why it is worth
  publishing now anyway: the next measurement needs something to be compared
  against.

  IT NAMES THE EXCLUSIONS.  The guest console and boundaries that had to wait
  out a stopped partition's window are both left out of the per-switch
  figures, both for stated reasons.  An exclusion a reader discovers reads as
  concealment whatever its justification.

The third architectural finding replaces the region-budget one, because the
ICC_PMR redirection is the more useful warning to somebody about to set
HCR.FMO for themselves.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The regression this phase exists to produce, on the model and on silicon.

Fourteen isolation cases in one run, seven in each direction: the
neighbour's data read and written, its code branched into, the ungranted
granule immediately next door, the hypervisor's own manifest, the GIC
distributor, and each partition's own window marked granule by granule and
read back.  Every case aims at an address of its own, so the fault it
provoked is attributable to it; every row is judged from two independent
sources, because a partition resumed past a faulting instruction cannot
report having been denied and a case that was never attempted leaves the
hypervisor nothing to look at.

And the critical partition's window period, measured continuously while the
untrusted one is steered through five behaviours in phases of one run.  On
silicon its period is 800,000 counts and it moves by nine while its
neighbour idles, fifteen while it computes with its own interrupts masked,
and sixty-nine while it violates its boundary a hundred and seventeen
thousand times.

Nothing a partition does through the schedule reaches its neighbour.  What
does reach it is the hypervisor's own console: a guest's output is one
hypercall per character through a polled UART, and a window ending with a
partial line has that line closed by the boundary handler, which delays the
next partition's entry by 24,000 counts.  That is measured, bounded by one
line of output, and left to be fixed by buffering the console off the
boundary path.

A stage-2 violation still STOPS the partition.  A build may ask at build
time to be resumed past the access instead, so that one image can sweep the
matrix rather than needing fourteen images and fourteen debug sessions; the
mode is the image's to enable, its default is the value of zeroed memory,
the trap vector takes the same route in every build, and halt is tested
rather than assumed by a build of the same image without it.

Four negative builds, each of which must fail and does, with its text
archived: a region limit one granule too generous, the per-partition time
freeze removed, a shared read-only granule its reader may write, and the
manifest overlap rule.  Three of them were WRONG first and passed, and each
time it was the runner's --expect fail that noticed rather than anything in
the image.

The whole of core/ is now on the 100% line-and-branch coverage floor.

The README's switch figure is requalified with it.  Re-measuring the
PREVIOUS commit's own code on the same bench reads three per cent higher
than the number it was published with, and this commit's tree a further two
-- none of which is attributable to any instruction added to the switch.
With the caches off the cost depends on where the code sits in memory, and
the oscillator the core runs on drifts.  Four significant figures was
over-precise; it is about six thousand cycles, plus or minus four per cent
on one bench.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
THE STRICT BUILD DID NOT BUILD, and had not since the major frame landed.
-Wconversion on three ULONG-to-uint32_t console calls in the schedule
report, and four (void) casts that do not silence warn_unused_result --
GCC deliberately ignores a cast there.  CI enforces that preset on every
push, so this was one pull request away from being found in public.

Then the MISRA-aligned warning set, by the method the Cortex-R52 Modules
port work established: re-compile every translation unit in every
configuration with the candidate flags and adopt the ones that come back
clean.  Twenty flags adopted over 293 distinct (source, -D set)
compilations across the host, the model and the board.  Two candidates
were run and REJECTED on evidence, and both are recorded with what they
found: -Wwrite-strings collides with ThreadX's pre-const _txe_*_create
signatures, and -Wunused-macros would force a fake use on every register
bit a driver deliberately leaves clear.  -Wredundant-decls paid for the
exercise: zx_board_init was declared in two headers, which is Rule 8.5.

AN EXCEPTION CLASS ZONEX DOES NOT HANDLE IS NOW PROVOKED.  HCR.TID1 and
an EL1 read of MPUIR give EC 0x03 on both targets: classified, named,
vector and syndrome agreeing, and the run carries on -- which is the
property that distinguishes an unhandled class from a hang.  Until now
the trap vector's ZX_RUN_TRAPPED arm and the classifier's last branch
were dead code.  Two routes were tried first and are recorded: WFI under
HCR.TWI hangs if the trap is absent, and a floating-point instruction
under HCPTR is UNDEFINED AT EL1 on the model, which has no FPU.

AND THE VECTORS THAT CANNOT RESUME NO LONGER RUN ON A SUSPECT STACK.
They reset SP_hyp before calling C -- the stack the fault arrived on may
be the reason for it -- and the C side counts its depth so that a fault
inside the fault report parks with its own exit code instead of
recursing for ever.  Neither guard has been provoked; that is recorded
rather than left to look finished.

THE CONSOLE.  A window ending mid-sentence had that line closed by the
BOUNDARY HANDLER: CR and LF into a polled UART, at EL2, with FIQ masked,
on the switch path, with a trip count decided by what the outgoing guest
had been printing.  It moved the critical partition's window period by
24,420 counts of the board's 8 MHz counter, against 69 counts for that
same neighbour violating its boundary a hundred thousand times.

The newline is now deferred to whoever speaks next, inside a window that
party owns.  The character stream is identical -- only the moment of the
write moves -- which is what makes it safe, and the host suite asserts
the text rather than a flag.  Sixty frames now measure 999, 1,159 and
1,196 counts.  A rare excursion of about 15,300 counts remains on the
HYPERCALL path, in roughly two six-hundred-frame runs in five; it is
measured, bounded, and NOT explained, and the console phase keeps its
own bound because of it.

AND THE HPRENR FIGURE THAT TWO IMAGES DISAGREED ABOUT WAS ARITHMETIC.
The switch measurement divided a loop's span by the round count and THEN
subtracted a whole counter read, charging one read to every iteration
where one is paid per loop.  Same bench, same session, after the fix:
217 cycles from the probe against 219 from the frame, where it had been
235 against 117.  Every per-component switch figure published before
this was understated by about one counter read.

Codegen identity, checked ELF by ELF before the fault-path work: all 49
hypervisor images byte-identical, with only the two guest images moving,
by 16 bytes in one function, from one deliberate fix.

Full silicon matrix run: five images pass, six negatives fail, each on
the check it was built to fail and no other.  FVP 24/24.  Host coverage
holds at 100% of lines and branches.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…WCET

Four new documents and two amended ones.  They exist because the
certification back end asks for evidence rather than for assertions, and
because most of what this pass produced is a REASON rather than a diff.

docs/hypercall-abi.md freezes the SHAPE the later phases inherit: a
sixteen-bit id space divided by what a call does to control flow, half of
it reserved to an integrator so nobody has to fork the list, a discovery
call that reports feature bits rather than a version to branch on, and
the rule that Phase 0 implements console output and the empty vector and
NOTHING else.  Sections 3 and 6 specify behaviour that is not built --
discovery and a defined error for an unimplemented id -- and say so in
both places: the two arrive together with the first fourth hypercall or
neither is worth having.

docs/errata.md is the sweep, and a sweep with no findings is only worth
something if what was searched is written down.  The part is r1p3, from
its own MIDR.  Seven Cortex-R52 errata are open there, four of them debug
and trace only; the other three do not apply and the reasoning is given
so a reader can check it rather than take it.  Two leave a standing
constraint -- HSCTLR.FI must stay clear, and an ISB before any SETEND --
and the first is the kind of bit a hypervisor arguing about interrupt
latency would be tempted by.  No S32Z2 erratum mentions EL2, stage 2,
HPRENR or HCR at all; one bears on device isolation in a later phase and
is recorded there rather than here.

docs/coverage.md is the other half of the decision not to hold the
suite's usual threshold repository-wide: which seven files are held to
100% of lines AND branches, what covers the port instead -- a build that
must fail, or one that must pass with a named check -- and the three
conditional-compilation axes whose every arm is assembled and executed
somewhere in the matrix.  Structural coverage of target code is named as
a later, funded concern and no number here stands in for it.

docs/wcet-inputs.md is the data-dependence list, read path by path.  The
dominant cost is CONSTANT: the EL1 MPU walk, whose trip count is a part
constant read once at boot.  What is data-dependent is named with what is
known about each -- the console on the hypercall path, the board driver's
guard spin bounded by an iteration count rather than by time, and burning
a stopped partition's window, which is bounded and deliberate.

decisions.md gains the counter-read arithmetic correction to D4 with the
corrected component table, the finding that D23's FPU argument cannot be
demonstrated on a model with no FPU, and an amendment to D28 saying what
the console phase was really measuring.  D29 records the lazy line close;
D30 records the stack and depth guards on the vectors that cannot resume,
including that neither has been provoked.

armv8r-el2-reference.md gains a table of which exception classes have
actually been TAKEN and how, and the measured fact that the model
implements no FPU.  One claim in it -- the CPACR-before-HCPTR check order
-- is marked as read from the architecture manual and NOT verified,
because the attempt to verify it is what found the model has no FPU, and
this sheet's convention is that anything unmarked was measured.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
D30 recorded two guards on the unresumable EL2 vectors -- SP_hyp reset
before calling C, and a depth count so that a fault inside the fault
report parks instead of recursing -- and recorded that NEITHER HAD BEEN
PROVOKED.  A branch nothing has ever taken is not evidence that it works,
which is the standard that put the tick-refusal build in the suite, so
the admission was a gap rather than a completed item.

zx_probe_refault.elf closes it.  It makes the region covering the board's
OWN CONSOLE read-only at EL2 and prints one character: the store takes a
data abort from Hyp mode, the vector resets the stack and calls the
report at depth one, the report's first console write faults for the same
reason, and the depth guard stops the second entry without printing.

On the S32Z280 it parks with zx_run_failures = 0x5C and the harness stops
cleanly on zx_console_run_parked rather than timing out.  Both guards are
now paths something has taken.  Without the depth guard the sequence has
no end and does not even overflow the stack -- each fault resets it -- so
it spins silently for ever, which is the failure the entry exists to
prevent.

THE RUN HAS NO CONSOLE AFTER ITS BANNER, by construction, so it is the
first build judged entirely from memory.  The model builds it and reports
it UNPROVOKABLE rather than passing: semihosting is not reached through a
region, so no access permission can refuse a write to it.  It is not in
CTest, for the reason the EL2-fault build is not.

AND IT FOUND A DEFECT IN THE HARNESS RATHER THAN IN THE GUARDS.  The gdb
script read zx_run_failures as a count of failing checks, so it reported
0x5C as "92 failing check(s) -- the console log is where they are named":
wrong about the number, and wrong about there being a log, for the one
build that denies itself a console on purpose.  The three fault-path exit
codes are named there now.  It had been misreporting 0x5A the same way
for as long as the EL2-fault build has existed.

MIDR_EL1 IS AN AArch64 SPELLING and had reached the errata document.  The
terminology check exists for exactly that and did not catch it, because
it selects files with git ls-files and the document was still untracked
when the check was run.  Corrected to MIDR, and worth knowing about the
checkers: they say nothing about a file until it is added.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The console phase of the determinism regression carried an unexplained
excursion of about 15,300 counts that appeared in roughly two runs in
five, with the board driver's write-one-to-clear guard as the leading
suspect.  The image now measures the whole path on the run it reports:
the longest guest console hypercall in core cycles, how many bytes that
one hypercall put on the wire, both of the driver's spins in iterations,
and -- directly -- how late each window boundary arrived against the
absolute deadline the ending window was armed with.

The suspect was wrong.  The guard has never spun: nought iterations,
every phase, every run.  The cost is the LINE TAG.  Nearly every console
hypercall writes the one byte the guest asked for; the one that opens a
line writes twenty-two -- the owed newline, the tag naming the partition,
and the guest's character -- at EL2 with FIQ masked, so the boundary
interrupt is held off for all of it.  106,214 to 106,352 core cycles over
seven runs: 2.2 ms, about 17,640 counts of this board's 8 MHz counter.

Nor was the residual rare.  A deferral that is constant costs a period
nothing, because a period is a difference and a constant cancels in it;
what reaches the number is the change.  Whether a boundary falls inside a
line tag is decided by a phase relationship between a fixed schedule and
a guest printing a fixed message, and two cycle-counter reads per
character -- under one per cent of a character time -- were enough to
move it.  Instrumented, the excursion is on every run: 17,830 · 17,889 ·
17,943 · 17,963 · 18,101 · 18,141 · 18,327.

That also closes the widened build, carried as unexplained for a step.
Instrumented it measures 12,557 · 13,132 · 13,714, and the correct
sixty-frame build now measures 15,909 and 17,327 -- larger.  The two
builds were never doing different things.

Both spins in the board driver are now bounded by TIME as well as by
iterations, sharing one deadline of ten character times computed from the
counter frequency and the baud rate.  The wait for the byte to go out had
no bound at all before this, which is the larger hole of the two.  Both
bounds are kept for the reason zx_el2_dwell keeps both: the time bound is
what a WCET argument reads, and the iteration bound is what survives a
counter that is not running.

The console phase's bound stays at half a window and is now derived
rather than observed: the worst deferral is one line tag, a period sees
it as one long entry and one short correction, so the worst jitter the
mechanism can produce is 35,280 counts against a bound of 40,000.

Host suite 100% lines and branches, full FVP suite green, and the silicon
matrix run on the S32Z280-594EVB with every negative build failing on the
check it was built to fail and on no other.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two kinds of statement, both of which asked a reader to trust something they
have no way to reach.

Eleven pointed at planning documents that are not in this repository. A
contributor who has only this repository learns nothing from "stays where the
roadmap put it", "the plan this work came from expected the opposite", or "the
spelling the focused task list uses" -- and a citation of a document nobody
else can open dates badly besides. Each one kept its fact and dropped its
citation, because the fact was always the content: the interrupt work is a
later phase, the split into assembly and C is better than one large routine,
and tx_hv_* was rejected for reading as a ThreadX subsystem. None of those
needed a source outside the tree to stand up.

One asserted a third party's design rationale with no source at all. The EL2
reference sheet said that Phase 0 had been expected to paravirtualise
interrupts because NXP's EL2M does, and that EL2M's reason is the shared GIC
Distributor rather than absent injection hardware. Every other claim in that
sheet is either measured here or cited to the technical reference manual, and
this one was neither. It has been replaced by what was actually established:
the part implements four List Registers, so injection is available, and Phase 0
keeps interrupt handling simple as a scope decision rather than a limitation
inherited by assumption. The conclusion is unchanged; what goes is an
unverifiable claim about somebody else's product.

The reference check passed on all twelve, and that is worth recording rather
than fixing blind. It matches path shapes and document identifiers, not prose,
so "the roadmap" walks straight through it. It has deliberately not been
extended: "the plan" is ZoneX's own name for the region layout that zx_mm_plan
produces, with eleven legitimate uses in the core and the examples, so a
pattern broad enough to catch the prose form would fire on every one of them.
A check that cries wolf gets bypassed and then catches nothing, which is the
reasoning already written into that script.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
It carried four checks and only three of them are the project's business.

The three that stay are ordinary hygiene, and CONTRIBUTING.md already
describes exactly these: a local absolute path, which resolves nowhere for
anybody else and in a build script means the script runs on one machine; a
citation of a numbered stage of something not in the tree, which a reader
cannot follow; and a tracked agent-instruction file, which no Eclipse ThreadX
repository carries.

The fourth enforced an authoring convention belonging to one working setup. It
rejected the identifiers of documents that live outside any repository -- and
to reject them a denylist has to spell them, which made this file the single
place those names appeared. Their spelling is of no use to anybody reading
this repository, and enforcement of how one contributor cites their own notes
does not belong in a project's continuous integration.

It was also not the check that works, and that is the part worth recording.
The shape that actually occurs is prose -- a sentence deferring to an external
document by description rather than by name -- and the removed pattern matched
none of it. Twelve such sentences were found by hand while preparing to
publish, in the documentation and in code comments both, every one of them
after that pattern had passed. What replaced it is a hook on the authoring
side, where the risk is, and where a commit message can be read as well: the
committed checks never see one.

Each remaining check was provoked and seen to fail before this went in, and
the removed one was confirmed silent, because a check nobody has watched fail
is not evidence that it can.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Five changes, and two of them correct something wrong rather than stale.

THE CONSOLE PARAGRAPH DESCRIBED A MECHANISM THAT IS NO LONGER THERE. It said a
window ending on a partial line has that line closed by the boundary handler,
at a cost of 24,000 counts. The boundary handler now writes nothing, and the
residual is on the hypercall path: one line tag, twenty-two bytes, 17,640
counts, with the boundary interrupt masked throughout. It also said the fix is
to buffer the console, and buffering was the rejected option -- what removes it
is an interrupt-driven driver with a polled fallback the fault path can force,
because the fault reporter prints when ZoneX has already failed once. The
claim is now split by cause: nothing a partition does through the schedule
reaches its neighbour, and what does reach it is the hypervisor's own driver.
That is a defect here rather than a limit of the partitioning, and the bound is
derived from the mechanism -- 35,280 counts against a half window of 40,000.

AND THE EL1 MPU WAS GIVEN THE WRONG DENOMINATOR. "A partition switch costs
about 6,000 cycles and the EL1 MPU is 85% of it" reads as 85% of the switch.
It is 3,407 cycles: 85% of the save and restore, and 60% of the switch.
docs/wcet-inputs.md had this right, so the README was the only place the two
were conflated. Both denominators are now stated. The claim that the split
holds "on a 32-region model" is gone with it: that is a timing proportion, and
timing on a functional model describes nothing.

The regression figures move to the six-hundred-frame run -- 21, 21, 22 and 304
counts -- because they are the larger and therefore the honest ones; the
sixty-frame numbers they replace read better and prove less. The switch figure
is quoted as a band across five readings rather than one run's triple, since
the paragraph's own point is that it moves.

A region-budget section is new, and it is the portability statement a reader
needs before choosing a board rather than after: the permitted EL2 region
counts, what the hypervisor's own device memory costs out of them and why that
is a property of the board, and the switch mask as a second budget that a
region can fall outside of with nothing left to fault on. A part configured
with no EL2 MPU cannot run ZoneX at all, and there is no software fallback.
Every number in it is measured on both targets, the model included.

The claim section is retitled from what Phase 0 WILL prove to what it does,
gains the positive claim it never carried, and loses one overclaim: a later
phase was described as delivering a CERTIFIED worst-case interrupt latency.
Nothing here is certified and that phase bounds rather than certifies. That
interrupt latency is not measured at all now leads its own paragraph instead of
sitting inside a list, because it is the first thing a safety reader has to
know, and the conditions every figure was taken under sit beside the claim so
that a quotation carries them.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
zx_api.h declared a major, a minor and a patch and stopped there, where every
other component of the suite declares five. Phase 0 is 0.1.0.202603 with no
hotfix, so ZX_BUILD_VERSION is 202603 and ZX_HOTFIX_VERSION is a space.

THE BUILD NUMBER IS YYYYQQ -- the year and then the QUARTER of publication,
not the month -- and that is written into the header rather than left to be
looked up, because the value reads as a month. 202603 is the third quarter of
2026. The neighbouring components carry the same shape and 202602 was Q2.
CONTRIBUTING.md already documents the scheme under "Version numbers"; what was
missing was the constants and a check.

So the host suite range-checks the quarter rather than pinning the whole
value. A version carrying a month satisfies every other property a version
has: it is an integer, it sorts, it is six digits, it looks like a date. It is
simply wrong by two quarters, and nothing would have said so. Pinning 202603
outright would have caught it too and would need editing every release, which
means being edited without being read. The lower bound is Q3 2025, when the
quarterly model was adopted.

The hotfix character is checked for being a space or a letter, because a digit
there would mean somebody had treated it as a number.

Both checks were provoked and seen to fail before this went in -- 202609
rejected, '1' rejected -- since a check nobody has watched fail is not
evidence that it can.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
A manifest could not name a memory type without a Cortex-R52 header.
ZX_ATTR_NORMAL_WB, ZX_ATTR_DEVICE and ZX_ATTR_NORMAL_NC were defined in
platform/cortex_r52/inc/zx_port.h, while the other three fields of the same
region descriptor -- AP, XN and SH -- were in core/inc/zx_manifest.h.

That split was an oversight against a rule this tree already states. The
include of zx_manifest.h in the port header carries the reason next to it:
the port programs what a manifest declares, so the descriptor belongs to the
manifest and not to the port, because the host-side validator has to build
and check the same objects with no Cortex-R52 header in reach.

The consequence was in the test suite the whole time. test/host cannot include
a port header, so test_zx_partition.c set an attribute index of bare 0 where
it meant Normal write-back -- the one field of four that had to be a magic
number. It names the type now.

WHAT SPLITS, AND WHERE THE LINE IS. The INDEX each memory type occupies is a
manifest's vocabulary and is now a contract in zx_manifest.h: a port may
choose the attribute byte that expresses "Normal, write-back" on its own
hardware, but not the index it sits at, because a manifest names the index.
The BYTES and the register values stay in the port, which is the only thing
here that is Cortex-R52-specific.

AND THE CONTRACT IS ASSERTED RATHER THAN DESCRIBED. zx_stage2_mpu.c now
extracts, from the HMAIR0 value it actually programs, the byte sitting at
each contracted index, and fails the build if it is not the byte that index
promises. Extraction rather than restatement: a restatement would be a second
copy of the same number and the two would drift together. A byte moved to the
wrong index would otherwise give every region naming that type the wrong
memory attribute, silently, with the validator satisfied and the hardware not
objecting -- which is the same failure mode the unwritten-index rule already
exists to prevent, one level up.

A range check runs first and masks the index inside the shift, so that an
index moved into HMAIR1 reports itself rather than reporting an undefined
shift by 32. Both were provoked: swapping two bytes in HMAIR0 fails the build
naming the type, and moving a type to index 4 fails it naming the register,
with no pedantic warning in either case.

No runtime behaviour changes. The values are the same values at the same
indices; what changed is which header owns them and that the agreement is now
checked. Verified by the case that exposed it: a static const manifest
compiles and passes zx_manifest_verify against common/inc and core/inc alone,
with no port header and no local workaround.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
ZX_MPU_GRANULE was the last piece of a manifest's vocabulary living outside
the manifest header. It is a rule about what a manifest may DECLARE -- every
base and every limit one carries is checked against it, and someone writing
one needs it to hand -- so it now sits with the AP, XN, SH and attribute-index
encodings rather than in the general API two headers away.

Nothing was broken before this and nothing is fixed by it; both headers are
architecture-independent, so this is cohesion rather than layering. It follows
the memory-type move for the same reason: a reader looking for what a region
descriptor may contain should find all of it in one place.

THE TESTS FOLLOWED THE CONSTANT, and that mattered more than the move.
test/host/test_zx_api.c includes zx_api.h and no other ZoneX header, which is
what makes it evidence that the header stands alone -- so it could not simply
gain an include. Its granule assertions moved to the manifest suite, beside
the alignment rules they underwrite, and the named memory types are asserted
there too. What stays behind is the alignment arithmetic itself, now written
against an explicit alignment argument: it was always exercising zx_addr_t and
the ZX_NODISCARD declaration rather than the constant, and saying so makes the
division obvious.

Verified from both ends: zx_api.h compiles as the only ZoneX header included,
and a static const manifest still compiles and passes zx_manifest_verify
against common/inc and core/inc with no port header.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The figures came from a run taken before the three commits that moved the
memory-type vocabulary and the region granule between headers. Those changes
are behaviour-neutral and the assertions binding them are compile-time, but a
README quoting silicon should quote the silicon the code ships as.

Re-run on the same board: the critical partition's period moves by 22, 20, 24
and 185 counts across the quiet and fault phases, and 17,951 while its
neighbour storms the console, whose longest hypercall is 106,116 cycles and
still exactly 22 bytes. The switch reads 5,662 / 5,703 / 5,982.

THE EARLIER RUN IS CITED BESIDE IT RATHER THAN REPLACED. Two independent runs
six days and three commits apart, agreeing to within the run-to-run scatter
and showing the identical 22-byte line tag, is a different claim from one
run's numbers -- and it is the only basis on which a single bench is worth
quoting. The 22 bytes matter most: that is the mechanism the half-window bound
is derived from, so its reproducing is what the bound rests on.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Four findings from reading the file against what the repository does. Two were
wording; two were the repository being wrong and the file describing it
faithfully.

THE TWO CHECKS WERE PATH-FILTERED, AND THEY MUST NOT BE. check_terminology.sh
and check_references.sh ran as a job inside host_tests.yml, which gates on the
build files, cmake/, common/, core/, scripts/ and test/host/. Both checks
select their input with git ls-files and scan EVERY tracked file, so any file
at all can carry a finding -- and a change touching only docs/ and platform/
ran neither of them.

That is not hypothetical. The commit earlier on this branch that removed
twelve unpublishable references touched exactly docs/ and platform/, and its
message notes that the reference check passed on all twelve. As a pull request
it would have merged with neither check having run. The file states the
principle this violates: a workflow that gates no pull request anybody opens is
worse than no workflow, because it looks like coverage.

A path filter is a property of a workflow rather than of a job, so the checks
move to repo_checks.yml with no paths on either trigger. A fifth workflow
rather than wider filters on the fourth: widening host_tests.yml would run a
full instrumented build and a coverage pass on every typo, where these two
need no toolchain, no build and no cache.

AND THE REFERENCE COMPILER WAS NOT THE ONE CI USED. The file names GCC 14 as
the Linux reference and says CI pins these versions. install.sh installed
build-essential, which on ubuntu-24.04 is GCC 13.3 -- measured, not inferred.
install.sh now installs gcc-14 and g++-14 alongside it, and host_tests.yml
names CC and CXX so the version is exact where it has to be. It deliberately
does NOT rewire the default gcc: a dependency installer should not change
which compiler every other build on a contributor's machine picks up, and the
file already says a local build with a different version is fine while a
verified one is not.

Built strict with GCC 14 before pinning it, because a newer compiler under
warnings-as-errors is exactly where new diagnostics appear: clean, and the
suite passes.

THE COVERAGE FLOOR WAS DESCRIBED AS A REPORT. "Pass coverage for a gcovr
report" undersold the strongest quality statement here: it writes a report and
then runs gcovr a second time over the seven fully reachable files of
core/src, and FAILS THE BUILD below 100% of lines or of branches. The failure
condition is now written down, along with why branches carry as much as lines.
The workflow step was called "Coverage"; it is now called what it does.

AND ZX_THREADX WAS NOWHERE IN THE FILE. A contributor reading only this one
could not learn that the model and silicon runners need it to build the
ThreadX guest images. Both runner rows now name it, with a worked invocation
and what happens without it -- the guests are skipped with a configure-time
message rather than an error, so most of the suite still runs, but a change to
the launch path or anything temporal is not verified until it has been run
with the variable set.

Verified while here: CMake 3.28 matches cmake_minimum_required, the gcovr pin
matches install.sh, the seven-file floor matches the script, the dev-targeting
rule and the component list are both still right.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Five places said the certification back end is funded, or called work
"funded": two in comments on the partition-switch path and the validator, and
three in docs/coverage.md and docs/wcet-inputs.md. All five publish with the
component.

None of them broke a rule and both checkers passed on all five. But a
statement about a project's budget is not a fact about the code, and a header
comment is an odd place to assert one -- it dates faster than anything around
it, it is not something a contributor can verify or act on, and where it sat
it was doing no work: what those comments needed to say is that the code is
INTENDED FOR CERTIFICATION, which is why MC/DC coverage has to stay
achievable and why an indirect call does not belong on the switch path. That
reasoning stands on its own and now says so without the budget.

The two headings lose it too. "Where the funded work should start" is a list
of the places a worst-case-execution-time analysis would begin from; whether
that work is resourced is somebody else's question and does not change the
list. "Structural coverage of target code belongs to the certification phase"
says what the section says and nothing more.

Comments only, plus two headings. No behaviour changes, and nothing that
rests on the reasoning moves: verified with both cross builds, the host suite
at 100% of lines and branches under GCC 14, and both repository checks.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Both failures were real and neither reproduced on this machine before it was
made to behave like a runner. That is the finding worth keeping.

THE ATfE PATH NEVER CROSSED INTO THE GUEST SUB-BUILD. ZoneX's clang toolchain
file takes ZX_ATFE_TOOLCHAIN_PATH; the ThreadX one the guest images are built
with takes ATFE_TOOLCHAIN_PATH, without the prefix. Nothing bridged the two,
and an ExternalProject inherits nothing that is not listed in its CMAKE_ARGS
-- which the file next door already says, about warnings-as-errors, for the
same reason.

So the guest fell back to whatever clang was on PATH. On a machine that keeps
ATfE at the default location that is the right compiler, which is why this
built here and has probably never been exercised anywhere else: the workflows
trigger on dev and main, and a fork's feature branch reaches neither, so the
clang lane had never seen this code until the pull request opened. On the
runner, ATfE is unpacked into the workspace and the default does not exist, so
it picked the system clang and could not compile for a bare-metal Cortex-R52.

The compiler's own directory is forwarded rather than the variable, because
that is right however the toolchain file found it -- passed on the command
line, or defaulted. Both boards, since both configure guest sub-projects.

AND THE COVERAGE FLOOR BROKE ON A gcov THAT DID NOT MATCH ITS gcc. That one is
mine, from the commit before: pinning CC=gcc-14 without pinning gcov left
gcovr shelling out to the image's default gcov, which refuses a .gcno written
by a different major -- "version 'B42*', prefer 'B33*'" -- and ends the run
with no coverage rather than with wrong coverage.

run.sh now derives gcov from CC when CC names a versioned gcc, and fails
loudly if the matching gcov is absent rather than producing that error a
second time. A run with CC unset is unchanged, so a plain local invocation
behaves as it did.

Verified by reproducing both conditions rather than by reasoning about them:
the clang lane with HOME pointed at an empty directory so the default ATfE
path is unavailable, and coverage from a clean build tree with CC=gcc-14 --
974 of 974 lines and 469 of 469 branches, and again with CC unset. Both GCC
cross builds and both repository checks still pass.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
@fdesbiens

Copy link
Copy Markdown
Contributor Author

A note on what the green checks cover: the fvp lane built every image but executed none, because FVP_AEMV8R_URL is unset on this repository — the lane says so in its job summary. So CI here demonstrates that the component compiles under four toolchains and that the repository checks pass; it does not demonstrate that any image runs.

That evidence exists and is in the pull request body — 24/24 on the Armv8-R AEM FVP and the full silicon matrix on an S32Z280-594EVB, both re-run on this revision — but it comes from a workstation and a development board rather than from CI. #6 records the gap and what closing it needs.

Every one of the seventeen was headed "MISRA C:2012 deviations (justified)",
and several contained the opposite: an argument that the rule is OBSERVED.
Two said so outright -- "observed rather than violated" -- and others
described assembly isolated in a one-line function, which is Directive 4.3
being complied with rather than deviated from.

The cost of that was a heading nobody could count from, and not slightly:
Directive 4.3 appears in eleven blocks and exactly two of them are deviations.
Anyone auditing by grepping the headings would have found eleven.

Three headings now, chosen per block by reading it:

  MISRA C:2012 deviations (justified)             7 blocks, one of which
                                                  records having none
  MISRA C:2012 notes: deviations and compliance   7 blocks carrying both
  MISRA C:2012 compliance notes                   3 blocks arguing a rule is
                                                  observed, recorded because
                                                  the question arises there

Ten blocks retitled; the seven that were already accurate are untouched.
Comment text is otherwise unchanged -- no justification was rewritten, added
or removed, and the box width of each line is preserved exactly, including the
three files whose header row was already one column wider than its
neighbours.

Verified as a no-op on the build: host suite at 974 of 974 lines and 469 of
469 branches under GCC 14, both cross builds, both repository checks.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two documents for two readers neither of the existing ones serves.

docs/phase-0-evidence.md answers "what did this establish, what did it cost,
and what should be built next" in one place. README.md is written for someone
cloning the repository, docs/decisions.md for someone changing the code, and
the documentation site for a ThreadX user; none of them is a summary of the
evidence. It carries the claim and the non-claim, the six-phase run that is
the central exhibit, the numbers with their conditions, the eleven builds that
must fail, the certification posture, and what the measurements de-risk.

It cites rather than restates wherever a derivation already has a home, so
what it adds is one copy of the measured figures rather than one copy of the
reasoning. What it will not do is imply more than was measured: interrupt
latency is not measured at all, there is no structural coverage of the port,
boot to first partition entry is not instrumented, and each of those is said
in the document rather than left to be noticed.

It also carries the maintenance rule the repository lacked. A silicon figure
lives in four prose files that have to move together, and two further copies
carry the DERIVED bound instead and do not move with a run. That distinction
has been paid for twice in one week; it is now written down.

docs/misra-deviations.md is the index over the per-site annotations. The
convention -- name the rule, justify it where it occurs -- was already met,
but a per-site record cannot answer how many deviations there are or where
they cluster. There are EIGHTEEN, across seven rules, and the useful fact is
the distribution: every one is in the port, the board support or the examples
except two, a single-point-of-exit deviation in the fault decoder and one
narrowing assignment in the schedule printer. Neither touches isolation or
timing, so the architecture-independent core that carries the coverage floor
is very nearly deviation-free and the deviations sit where the code meets the
hardware.

Compiling it is what found the heading problem fixed in the commit before,
and it lists the compliance notes separately so that the count is not inflated
by them.

Both are explicit about what they are not. The deviation index is assembled by
reading source rather than by a checking tool, so it is evidence of discipline
and not of coverage; a qualified record, tool-produced against a stated rule
set and accounting for every rule rather than only the deviated ones, belongs
to the certification package and does not exist.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
@fdesbiens
fdesbiens merged commit 667969d into eclipse-threadx:dev Sep 9, 2026
6 checks passed
@fdesbiens
fdesbiens deleted the feature/zonex-core branch September 9, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant