Files
pm3py/docs/plans/2026-07-05-14a-custom-commands-plan.md
michael 615d4a6115 docs: roadmap index + custom-14a-command plans + backfill plan docs
- Add docs/plans/README.md as the roadmap index cataloguing all design/plan docs.
- Add the custom ISO14443-A command handling design + plan (L3 NTAG I2C
  SECTOR_SELECT / cross-sector reads + native auth; L4 static APDUs + WTX relay),
  produced from a multi-agent design workflow.
- Backfill previously-uncommitted plan docs (trace-formatter, ndef-trace-decode,
  live-sniff, firmware-upstream-rebase, 14a-live-trace) and NTAG5_SECURITY.md.
- Sync CLAUDE.md package structure with the committed transponder models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:48 -07:00

249 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Custom ISO14443-A command handling — Implementation Plan
**Status:** Planned, unimplemented · **Date:** 2026-07-05 · **Design:** [2026-07-05-14a-custom-commands-design.md](2026-07-05-14a-custom-commands-design.md)
Serve custom/vendored ISO14443-A commands in the card sim, layered on the native-EML
14a sim. Two independently shippable tracks: **L3** (NTAG I2C SECTOR_SELECT + cross-sector
reads, static vendor reads, native auth) and **L4** (static ISO-DEP APDUs + WTX relay for
dynamic crypto). Reuses the proven 15693 `sim_table` server, the 120-byte `TableEntry`
format, and the `TableCompiler`. All firmware line numbers are anchors against the current
`firmware/armsrc/iso14443a.c`; each firmware change lands as an atomic single-file commit
anchored to **stable symbols** (`ISO14443A_CMD_RATS`, the final `else`,
`EmSendPrecompiledCmd`), not line numbers.
---
## L3 track — mechanisms
- **(A) Native-EML (unchanged):** REQA/WUPA/anticoll/SELECT/PPS/GET_VERSION/READ_SIG/
READ/FAST_READ/WRITE/COMPAT_WRITE/READ_CNT/INCR_CNT/HALT/RATS. Byte-correct within
86µs, zero new code. WRITE mutates the EM BigBuf so later reads reflect writes natively.
- **(B) Native-EML + sector offset (graft):** cross-sector READ/FAST_READ.
`sim_active_sector()` maps the `sim_table` active-group bits → `{0,1,3}`; native READ
(`:2033-2058`) and FAST_READ (`:2081-2093`) add `sim_active_sector()*0x400` to the
`emlGet` source offset. Python pre-zeroes invalid pages in the uploaded multi-sector EML
image so native 00-fill falls out for free. Reads stay O(1)-native, sector-correct.
- **(C) Static `sim_table` (new `:2493` hook):** the SECTOR_SELECT *state machine* +
genuinely-custom static reads only (see worked example). No per-page entries.
- **(D) Native firmware auth (existing branches):** UL-C 3DES (`:2242`), UL-AES
(`:2273/2324/2355`), MFC Crypto1 — model key in EML, firmware computes challenge/response,
first cut fixed-nonce. Not table, not relay.
**L3 miss policy:** reader-retry relay is debug-only (86µs FDT is unreachable over USB).
Everything reachable at L3 is covered by AD.
## L4 track — mechanisms
Gated by a new `iso_dep_active` flag (set at RATS→ATS `:2234`; cleared on HALT / S(DESELECT)).
- **(E) Static `sim_table`** (same `:2493` hook): deterministic ISO-DEP APDUs — Type-4
NDEF SELECT-App(AID) / SELECT-File(CC,NDEF) / ReadBinary, canned GET/PUT DATA, fixed
`90 00`. Firmware masks the PCB (`(pcb&0xE2)==0x02` = I-block), computes
`off = 1 + CID + NAD`, strips the trailing CRC, `MATCH_PREFIX` on the bare APDU; on hit
re-wraps (`response[0]=pcb`, CID re-inserted at `response[1]` when `pcb&0x08`, payload at
`response+off`, `AddCrc14A`). **Overrides the bogus blanket `90 00`** at `:2529-2545`
which is exactly why the hook sits at `:2493` (before the switch), not `:2576`.
- **(F) WTX relay** (dynamic crypto): on an I-block table miss with `iso_dep_active`,
firmware sends a tag-side **S(WTX)** to buy time (FWT×59 ≈ 4.8s), relays the normalized
APDU to Python, spin-waits for the computed response, re-wraps and sends. The Python
transponder model owns per-session nonce/IV/auth state.
**L4 chaining** (gating dependency for DESFire/NDEF coverage): tag block-number toggle
(PCB bit0 per I-block), reader→tag reassembly (PCB bit4 → R(ACK) `0xA2|bn`), tag→reader
FSD split into chained I-blocks. `DYNAMIC_RESPONSE_BUFFER_SIZE` bumped `64→256` first.
v1 gated to single-frame + `0xAF` continuation until chaining lands.
---
## Firmware steps
| ID | File | Change |
|----|------|--------|
| **FW-1** | `armsrc/sim_table.h` + `.c` | Generalize `sim_table_execute` from `(iso15_tag_t *tag)` to `(uint8_t *eml_data, uint16_t eml_size, uint32_t *auth_state)`; replace `tag->data`/`sizeof(tag->data)`/`tag->auth_state` at `sim_table.c:132-169`. Drop `#include iso15.h` from the header. Add `#define SIM_FLAG_RESP_4BIT 0x04`. Struct unchanged (120 bytes PACKED). Fork-local. |
| **FW-2** | `armsrc/iso15693.c` | Update the single `sim_table_execute` call site (`~:3081`) to the new signature: `(tag->data, sizeof(tag->data), &tag->auth_state)`. Keeps 15693 green. |
| **FW-3** | `armsrc/iso14443a.c` (commit A) | `#include sim_table.h`. Add file-scope `static uint32_t s_auth_state` and `sim_active_sector()` near the sim-trace statics (`~:1727`); reset `s_auth_state=0` in the `FLAG_SIM_TRACE` arm. Bump `DYNAMIC_RESPONSE_BUFFER_SIZE 64→256` and the modulation buffer. Insert **host-poll** block at loop top after `sim_trace_flush` (`~:1907`): `CMD_SIM_TABLE_UPLOAD` (4-byte `initial_groups` + 120B entries → `sim_table_init/add`), `UPDATE`, `CLEAR`, `CMD_HF_MIFARE_EML_MEMSET` (`emlSet`), `CMD_BREAK_LOOP`. Copied ~verbatim from `iso15693.c:2472-2508`. |
| **FW-4** | `armsrc/iso14443a.c` (commit B) | **L3 table-check** at `:2493` (after the `response_n`/`modulation_n` reset, before `tagType==10` and the fake-`90 00` switch): `sim_table_lookup` + `sim_table_execute`; honor `SIM_FLAG_RESP_4BIT` (`EmSend4bit`), `response_len==0` (passive-ACK silence), byte response (join the `:2591` build path), `SIM_FLAG_APPEND_CRC`. **Sector-offset graft** on native READ/FAST_READ (`sim_active_sector()*0x400`). Add labels `build_send:` (`:2591`) and `after_send:` (past `:2620`). |
| **FW-5** | `include/pm3_cmd.h` | Append `CMD_SIM_RELAY_EVENT 0x0953` and `CMD_SIM_RELAY_RESP 0x0954` in the existing `0x095x` block. Additive. |
| **FW-6** | `armsrc/iso14443a.c` (commit C) | Add file-scope `bool iso_dep_active` (near `order` `:1870`); set at RATS→ATS (`:2234`), clear on HALT (`:2214`) and S(DESELECT). Extend the `:2493` hook: detect I-block, `off=1+CID+NAD`, strip PCB/CID/NAD+CRC, `MATCH_PREFIX` on the bare APDU; on hit re-wrap and join `build_send`. Overrides fake `90 00`. |
| **FW-7** | `armsrc/iso14443a.c` (commit D, then E) | **D:** `EmSendWtx(uint8_t wtxm)` tag-side S(WTX) sender modeled on `EmSendCmd` (the `:3787-3808` `send_wtx` is reader-side, not reusable). On L4 miss: `EmSendWtx(59)` → await reader S(WTX) echo (`(b&0xF2)==0xF2`) → `reply_ng(CMD_SIM_RELAY_EVENT,{L4,apdu})` → spin `data_available()`/`receive_ng()` for `CMD_SIM_RELAY_RESP` (GetTickCount timeout) → re-wrap + `EmSendCmd`. **E:** block-number toggle + reader→tag chaining reassembly + tag→reader FSD split. |
## pm3py steps
| ID | File | Change |
|----|------|--------|
| **PY-1** | `pm3py/core/protocol.py` | **FIX id drift:** repoint `SIM_TABLE_UPLOAD/CLEAR/UPDATE` from stale `0x0900/0901/0902``0x0950/0951/0952`. Add `SIM_RELAY_EVENT=0x0953`, `SIM_RELAY_RESP=0x0954`. Reuse `HF_MIFARE_EML_MEMSET=0x0602` for EML load. |
| **PY-2** | `pm3py/sim/table_compiler.py` | Add `RESP_FLAG_4BIT=0x04` + group constants (`GRP_SEL_PENDING=1, GRP_SECTOR0=2, GRP_SECTOR1=3, GRP_SECTOR3=4`). Add `compile_ntag_i2c(tag)`: `base=compile_14a(tag)` + **only** the SECTOR_SELECT machine (no per-page reads). Add an `enumerate_table_entries()` hook so a model can yield full `TableEntry` objects (groups/flags) alongside the legacy `enumerate_responses` 3-tuple. |
| **PY-3** | `pm3py/transponders/hf/iso14443a/nxp/ntag_i2c.py` | Add `build_eml_image()` laying out `[mfu prefix][sector0, invalid pages zeroed][sector1][sector3 regs]` at `0x400` stride, reusing `_masked_page`/`_page_readable`. Add `enumerate_table_entries()` yielding the SECTOR_SELECT machine. Keep `enumerate_responses` (sector-0) for other consumers. |
| **PY-4** | `pm3py/sim/sim_session.py` (L3) | Fix constants. In `start_14a`: if the tag is table-backed, load the EML image (`CMD_HF_MIFARE_EML_MEMSET`) then `_compile_and_upload_table` with a 14a dispatch branch (`NtagI2C→compile_ntag_i2c`, `MifareClassic→compile_mifare`, else `compile_14a`). Factor the `initial_groups`+120B chunked uploader into a shared `_upload(table, initial_groups)`. |
| **PY-5** | `pm3py/sim/sim_session.py` (L4) | Async relay path for L4-crypto tags: send `HF_ISO14443A_SIMULATE` via async `PM3Transport`, `create_task(self._relay_loop(tag))`; **do not** spawn the sync `_trace_reader` thread (single async reader owns trace + relay on one fd). Flesh out `_relay_loop`: `CMD_SIM_RELAY_EVENT``RESELECT``tag.power_on()`; `L4``out=await tag.handle_frame(RFFrame.from_bytes(apdu))`, reply `CMD_SIM_RELAY_RESP` with `resp_flags` + bare bytes. |
## New / reused command IDs & flags
- `CMD_SIM_RELAY_EVENT = 0x0953` **(new)** fw→host: normalized L4 APDU on table miss.
- `CMD_SIM_RELAY_RESP = 0x0954` **(new)** host→fw: computed L4 response.
- `CMD_SIM_TABLE_UPLOAD/CLEAR/UPDATE = 0x0950/0951/0952` **(reused;** fix `protocol.Cmd` stale `0x0900` series).
- `CMD_HF_MIFARE_EML_MEMSET = 0x0602` **(reused)** to load the multi-sector 14a EML image (native sim already reads tag memory from the EM BigBuf via `emlGet`).
- `SIM_FLAG_RESP_4BIT = 0x04` **(new)** response-flag (fw `sim_table.h` + `RESP_FLAG_4BIT` in `table_compiler.py`) — not a wire command.
- `GRP_SEL_PENDING=1 / GRP_SECTOR0=2 / GRP_SECTOR1=3 / GRP_SECTOR3=4` — compiler-side group-bit convention (max 31 groups), not firmware constants.
## Wire formats
**`sim_table_entry_t`** (120-byte PACKED, reused byte-for-byte, LE
`<32s B B 64s B B B H B B B B B I I B B B B>`): `match[32]@0`, `match_len@32`,
`match_mode@33` (0=EXACT, 1=PREFIX), `response[64]@34`, `response_len@98`,
`response_flags@99` (bit0=`APPEND_CRC`, **bit2=`RESP_4BIT`** new), `eml_action@100`,
`eml_offset(u16)@101`, `eml_len@103`, `eml_resp_insert@104`, `cmd_data_offset@105`,
`cmd_data_len@106`, `group@107`, `activate_groups(u32)@108`, `deactivate_groups(u32)@112`,
`set_auth@116`, `clear_auth@117`, `flags@118` (bit1=`CONSUME`), `_pad@119`.
`TableEntry.serialize()` already emits exactly this.
**Upload** (unchanged): `CMD_SIM_TABLE_UPLOAD` first frame = 4-byte `initial_groups` (LE)
+ N×120B entries filling `512-4` bytes; `CMD_SIM_TABLE_UPDATE` = `(512//120)*120`B chunks;
`CMD_SIM_TABLE_CLEAR` = empty.
**Relay event** (`CMD_SIM_RELAY_EVENT` payload): `byte[0]=ev_type` (0=RESELECT, 3=L3-debug,
4=L4), `byte[1]=norm_len`, `byte[2..]=normalized frame` (L4: PCB/CID/NAD+CRC stripped).
**Relay resp** (`CMD_SIM_RELAY_RESP` payload): `byte[0]=ev_type echo`, `byte[1]=resp_flags`
(bit0=append-CRC, bit1=4BIT, bit2=passive-ACK/no-tx, bit4=chaining-more), `byte[2..]=bare
response`. `tag.handle_frame` returns **bare bytes** — firmware owns PCB/CID/NAD framing
and block-number.
## NTAG I2C worked example (NT3H2211, 2k) — reading Sector 1
**Compile (`compile_ntag_i2c`):** `initial_groups` seeds `GRP_SECTOR0` active. Multi-sector
EML image loaded via `CMD_HF_MIFARE_EML_MEMSET`. ~6 table entries (far under any FDT/entry
limit):
- `E1` `[C2 FF]` EXACT → `[0A]` `RESP_4BIT`, activate `GRP_SEL_PENDING`.
- `E2` `[00 00 00 00]` EXACT, group=`GRP_SEL_PENDING`, `response_len=0`, activate
`GRP_SECTOR0`, deactivate `SECTOR1|SECTOR3|SEL_PENDING`.
- `E3` `[01 00 00 00]``GRP_SECTOR1`. `E4` `[03 00 00 00]``GRP_SECTOR3`.
- `E5` `[]` PREFIX (last) in `GRP_SEL_PENDING``[00]` 4-bit NAK, deactivate `SEL_PENDING`.
**Runtime:**
1. Native anticoll/SELECT (unchanged). `sim_active_sector()==0`.
2. Reader → `C2 FF``:2493` hit `E1``EmSend4bit(0x0A)`, `active_groups |= SEL_PENDING`.
3. Reader → `01 00 00 00` (bare sector byte) → `:2493` in `SEL_PENDING` hit `E3`
`response_len==0` ⇒ apply state (+`SECTOR1`, clear others + `SEL_PENDING`), **transmit
nothing** = the >1ms passive-ACK. `sim_active_sector()` now returns 1.
4. Reader → `FAST_READ 3A 00 0F`**native** FAST_READ fires first (correct), reads
`emlGet` at `base + 1*0x400` = sector-1 memory, returns via `EmSendCmd`. Sector-correct,
O(1), wire-speed, no table scan, no USB.
5. Back to sector 0: `C2 FF` (E1), `00 00 00 00` (E2 → `SECTOR0`, silence).
6. Invalid sector: `C2 FF` then `07 00 00 00``E5` → 4-bit NAK, `SEL_PENDING` cleared.
All frames trace via `EmLogTrace`/`sim_trace_push` automatically.
## Stateful command handling — five representations
1. **In-table group state** (no host, wire-speed): SECTOR_SELECT active sector,
SLIX2-style privacy, PWD_AUTH-gated read sets. `activate_groups`/`deactivate_groups`
masks; two-phase handshakes use a transient PENDING group so a stray frame can't
false-flip; silent success = `response_len==0`; 4-bit ACK/NAK = `RESP_4BIT`.
2. **Firmware sector projection** (the graft): `sim_active_sector()` derives the active
sector *read-only* from `active_groups` — single source of truth, native reads O(1).
3. **EML-backed mutable state** (no host): live WRITE persists into EM BigBuf; later
native reads reflect it.
4. **Native-auth state** (no host): UL-C/UL-AES/MFC via existing firmware branches with
key in EML; `auth_state` word + firmware nonce chain carry session state. First cut
fixed-nonce.
5. **Relay state** (host, L4 only): dynamic ISO-DEP crypto; Python owns per-session
nonce/IV/auth in `handle_frame`. A challenge-prefix entry may be `SIM_FLAG_CONSUME`
(one-shot) to hand the encrypted tail to the relay.
**Session reset:** on REQA/WUPA/HALT firmware resets `active_groups` + `iso_dep_active`
and (for L4 relay tags) emits `CMD_SIM_RELAY_EVENT{RESELECT}` so Python calls
`tag.power_on()`, keeping the C and Python state machines in lockstep across a reader
give-up → re-anticollision.
## Test plan
- **UNIT (pm3py):** `TableEntry.serialize()` round-trips `RESP_4BIT`/group fields at exact
byte offsets (99, 107-118), `sizeof==120`.
- **UNIT:** `compile_ntag_i2c` emits exactly the E1-E5 machine (correct match/groups/masks/
flags); assert **no** per-page sector reads.
- **UNIT:** `build_eml_image()` lays out `[prefix][sector0 zeroed-invalid][sector1]
[sector3]` at 0x400 stride with per-sector PWD/PACK masking (no secret leakage).
- **UNIT:** `protocol.Cmd` drift fixed (`SIM_TABLE_* == 0x0950/1/2`, relay ids); sim_session
constants match; FLAG_SIM_TRACE bit confirmed against firmware.
- **UNIT:** `_relay_loop` parses `CMD_SIM_RELAY_EVENT{L4}`, calls `handle_frame`, replies
with correct `resp_flags`; RESELECT triggers `power_on`.
- **FIRMWARE UNIT (host build of `sim_table.c`):** group gating + activate/deactivate
transitions E1→E3; `response_len==0` path; `RESP_4BIT` path; `sim_active_sector()` maps
bits → `{0,1,3}`.
- **FIRMWARE UNIT:** worst-case `sim_table_lookup` latency on the 48MHz ARM for the ~30-entry
table vs 86µs FDT — **measure, do not hand-wave** (must-pass gate).
- **HARDWARE L3:** PM3 Easy sims NT3H2211; reader issues `C2 FF` + `01 00 00 00` +
FAST_READ; live-trace confirms 4-bit ACK, >1ms silence, sector-1 data, invalid NAK; and
that a table hit fully suppresses the fake-`90 00` path.
- **HARDWARE L3:** verify passive-ACK phase-2 emits **nothing** (no spurious frame the
reader misreads as NAK).
- **HARDWARE L4 static:** Type-4 NDEF SELECT-App/File/ReadBinary from the table, overriding
fake `90 00`; TagInfo reads NDEF.
- **HARDWARE L4 relay:** DESFire GET_VERSION (`0xAF` chaining) and a >FSD NDEF read via WTX
relay; assert S(WTX) echo, block-number toggle, chained reassembly both directions.
- **REGRESSION:** 15693 sim still works after the `sim_table_execute` signature change; full
pytest suite green.
## Sequencing
**L3 track (ship first, independently testable):**
1. FW-1 (`sim_table` signature + `RESP_4BIT`) → FW-2 (`iso15693.c` call site, keeps 15693 green).
2. FW-3 (commit A: include, statics, buffer bump, host-poll upload).
3. FW-4 (commit B: L3 table-check + sector-offset graft).
4. PY-1 (id fix) → PY-2 (`compile_ntag_i2c`) → PY-3 (`build_eml_image` + entries) → PY-4 (`start_14a` upload + dispatch).
5. **Test L3 on hardware.** Commit + bump firmware gitlink.
**L4 track (independent, builds on the L3 host-poll hook):**
6. FW-5 (`pm3_cmd.h` relay ids).
7. FW-6 (commit C: `iso_dep_active` + static-APDU serve overriding fake `90 00`).
8. FW-7 (commit D: `EmSendWtx` + WTX relay; commit E: block-number toggle + chaining).
9. PY-5 (async `start_14a` relay path + `_relay_loop`).
10. **Test L4 on hardware** (DESFire chaining, NDEF>FSD).
## Risks & mitigations
| Risk | Mitigation |
|------|------------|
| **FDT scan blowout** — linear `sim_table_lookup` approaches 86µs on a big table. | Sector-offset graft keeps the 14a table tiny (~6-30 entries); cross-sector reads are native (O(1)). Firmware-host unit test **measures** worst-case latency vs 86µs before shipping. Contingency: first-byte bucket index in `sim_table.c` (benefits 15693 too). |
| **Dispatch order** — native READ/FAST_READ fire before `:2493` and would serve flat sector-0 data. | Resolved by design: cross-sector reads are **not** routed through the table; native branches are made sector-aware via one `emlGet` offset. Native-first is now correct. |
| **Passive-ACK phase-2** emits a spurious frame the reader reads as NAK. | `sim_table_execute` sets `*resp_len=0`; hook sets `p_response=NULL` and jumps past send; the `if(response_n>0)` build guard yields silence. Hardware test asserts >1ms silence. |
| **Command-id drift** — `protocol.py` at stale `0x0900` series mismatches framing. | PY-1 fixes it as the first Python commit; unit test asserts the values. |
| **UL-C/UL-AES miscategorized** as L4. | Routed to existing native firmware auth branches with model key via EML; documented fixed-nonce fidelity gap. Not claimed under table/relay. |
| **L4 relay claims DESFire/NDEF** but chaining/block-number don't exist in FW. | Chaining is an explicit gating commit (FW-7 E) before DESFire is claimed; buffer bumped to 256 first; v1 gated to single-frame + `0xAF`. |
| **`iso14443a.c` is a hot upstream file** — hooks churn on rebase. | Atomic single-file commits anchored to stable symbols; heavy logic in self-contained blocks; `sim_table`/`sim_crypto` fork-local; only one 15693 call site changes. |
| **CONSUME one-shots** not reset until re-UPLOAD. | Session-reset emits `RESELECT`; where a CONSUME challenge must re-arm, Python re-uploads/re-adds; documented constraint. |
## Open decisions
1. **`FLAG_SIM_TRACE` bit:** `sim_session.py` uses `0x2000`, the 14a live-trace plan doc
says `0x4000`; confirm the firmware's actual bit before wiring the async relay.
*(Note: reconciled this session — firmware F1 landed `FLAG_SIM_TRACE 0x2000`; the doc's
`0x4000` is stale. Verify against `pm3_cmd.h`.)*
2. **Multi-sector EML layout:** confirm the EM BigBuf `CARD_MEMORY` fits sector0+1+3
(~2KB + config) and that `CMD_HF_MIFARE_EML_MEMSET` writes at the offset the sector graft
expects; else add a dedicated `CMD_HF_ISO14443A_EML_SETMEM` (fallback id `0x033A`).
3. **NTAG I2C no-rollover fidelity:** native READ may roll over at emulator-memory end, not
sector end. Decide pre-zeroing vs an explicit sector-length clamp in the READ branch.
4. **NTAG21x first-READ NFC counter bump** is a side effect the native/static path loses.
Accept as documented deviation, or force that first READ through a CONSUME entry (same
for EV1 INCR_CNT edges).
5. **UL-C 3DES fixed nonce vs model random `RndB`:** acceptable for sim; decide whether
firmware 3DES-with-supplied-nonce is wanted or a downscope note suffices.
6. **L4 chaining scope for v1:** minimum viable = single-frame APDUs + DESFire `0xAF` +
tag→reader FSD split. Confirm whether reader→tag chaining is needed for any target
reader in the first cut or can be deferred.
7. **Empirical reader behaviour:** do target NTAG I2C reader stacks actually issue
SECTOR_SELECT for sector 1, or only read sector-0 NDEF? If the latter, sector-1 can be
validated later without blocking the L3 ship.
8. **Apply hooks to `SimulateIso14443aTagAID` (`:4716`)?** Deferred; `TagEx` is the primary
`FLAG_SIM_TRACE` path.
## Provenance
Produced by the `plan-14a-custom-commands` design workflow (13 agents: 6 readers → 3
designs → 3 adversarial judges → synthesis). Judge outcome: static-table-first and hybrid
"adopt-with-changes" (reuse 5/5, fdt_safety 3-4); relay-first "reject" (FDT-unsafe at L3).
The recommended architecture is static-table-first L3 grafted with a firmware sector
projection to resolve the SECTOR_SELECT/entry-explosion risk, plus a WTX relay for dynamic
L4 crypto.