diff --git a/docs/05_architecture_overview.md b/docs/05_architecture_overview.md
index 081e470..2d329d5 100644
--- a/docs/05_architecture_overview.md
+++ b/docs/05_architecture_overview.md
@@ -181,31 +181,64 @@ have no path from a frame name to the actual byte layout for the currently
loaded LDF — and worse, the test bench would happily ship bytes encoded
against a *stale* LDF baked into the generator's last run.
-Concretely, a single `fio.send` call traverses both layers:
+### Three independent entry points, one wire
-```
-test code
- |
- | AlmReqA.send(fio, AmbLightColourRed=0, ...)
- v
-tests/hardware/_generated/lin_api.py <-- typed names, compile-time check
- |
- | fio.send("ALM_Req_A", AmbLightColourRed=0, ...)
- v
-tests/hardware/frame_io.py <-- per-instance frame cache
- |
- | ldf.frame("ALM_Req_A").pack(AmbLightColourRed=0, ...)
- v
-ecu_framework/lin/ldf.py <-- runtime pack/unpack
- |
- | raw_frame.encode_raw({...})
- v
-ldfparser <-- bit-level layout from LDF on disk
+A tester has three legitimate ways to drive the bus, all converging at
+`LinInterface`. They are **parallel paths**, not a single nested stack —
+`FrameIO` deliberately has no static dependency on `ecu_framework/lin/ldf.py`
+(its only `ecu_framework` import is `LinInterface` + `LinFrame` from
+`lin/base.py`), so the `ldf` it receives can be any object with a
+`.frame(name)` method.
+
+```mermaid
+flowchart TB
+ T[test code]
+
+ subgraph Paths[three independent ways to address a frame]
+ GEN["gen_lin_api typed wrapper
AlmReqA.send(fio, ...)
compile-time name check"]
+ FIO["FrameIO stringly-typed
fio.send('ALM_Req_A', ...)
per-instance frame cache"]
+ LDFDIRECT["LdfDatabase directly
ldf.frame('ALM_Req_A').pack(...)
returns bytes, no I/O"]
+ end
+
+ T --> GEN
+ T --> FIO
+ T --> LDFDIRECT
+
+ GEN -.delegates.-> FIO
+ FIO -.duck-typed lookup.-> LDFOBJ[ldf-like object
currently LdfDatabase]
+ LDFDIRECT --> LDFOBJ
+ LDFOBJ --> LDFPARSER[ldfparser - bit layout]
+
+ FIO --> LIN[LinInterface.send / receive]
+ LDFDIRECT -->|caller invokes lin.send
with the packed bytes| LIN
+ LIN --> WIRE[wire]
```
-Each layer's responsibility is unique to that layer; removing either
-collapses a distinct kind of check (compile-time name validation, or
-runtime LDF-driven byte layout) that the other layer cannot provide.
+What each path buys you:
+
+- **`gen_lin_api`** — compile-time name validation. Typo a frame or signal
+ name and the IDE / mypy / pytest collection rejects it before any LDF
+ is read. Delegates the actual packing to `fio.send`.
+- **`FrameIO`** — stringly-typed I/O over the wire. Caches frame
+ lookups, supports raw escape hatches (`send_raw` / `receive_raw`) that
+ bypass the LDF object entirely.
+- **`LdfDatabase` directly** — schema-only access. Useful when a test
+ wants to inspect frame layout, pack a buffer without sending, or hand
+ the bytes to a non-FrameIO transport.
+
+The LDF object (currently `LdfDatabase`) is consumed by both `FrameIO`
+and any direct-use code path. `FrameIO`'s use is via injection — it
+never imports `LdfDatabase` and can be tested against a stub.
+
+Removing any of the three entry points collapses a distinct affordance:
+
+- Drop `gen_lin_api` → tests keep stringly-typed `fio.send("ALM_Req_A", …)`
+ and hand-copied state constants, both of which silently drift when the
+ LDF changes.
+- Drop `FrameIO` → every test that wants high-level I/O has to wire
+ `LinInterface` + LDF lookup + pack/unpack itself.
+- Drop direct `LdfDatabase` usage → tests can no longer pack a frame
+ without sending it, or inspect frame metadata without an I/O attempt.
## Extending the architecture