aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorFelix Morgner <felix.morgner@ost.ch>2026-09-02 16:33:03 +0200
committerFelix Morgner <felix.morgner@ost.ch>2026-09-02 16:33:03 +0200
commitde85ad8d0558d0950506a7e64e7a5f2d48dc8485 (patch)
tree1560e809732fc40c7d99c2190d41f6576476b071 /docs
parent60b06d950c0716bfcb6dcde84ee248cb09433727 (diff)
downloadkernel-de85ad8d0558d0950506a7e64e7a5f2d48dc8485.tar.xz
kernel-de85ad8d0558d0950506a7e64e7a5f2d48dc8485.zip
docs: add additional brief and guide drafts
Diffstat (limited to 'docs')
-rw-r--r--docs/briefs/tb0003-facet-based-capability-dispatch.rst8
-rw-r--r--docs/briefs/tb0004-device-tree-ownership-and-registries.rst226
-rw-r--r--docs/briefs/tb0005-device-resources.rst241
-rw-r--r--docs/briefs/tb0006-compile-time-driver-self-registration.rst326
-rw-r--r--docs/guides/device-drivers.rst250
5 files changed, 1049 insertions, 2 deletions
diff --git a/docs/briefs/tb0003-facet-based-capability-dispatch.rst b/docs/briefs/tb0003-facet-based-capability-dispatch.rst
index 321c2694..2cace2f3 100644
--- a/docs/briefs/tb0003-facet-based-capability-dispatch.rst
+++ b/docs/briefs/tb0003-facet-based-capability-dispatch.rst
@@ -387,12 +387,14 @@ Drawbacks and Limitations
Stated as plainly as the benefits above, because a design write-up that only lists advantages is not documenting an architecture — it is advertising one.
* **String-keyed identity has no uniqueness guarantee.** ``facet_id`` equality is ``std::string_view`` content equality. Two unrelated facets that happen to share a literal — a typo, a copy-pasted ``id`` line, or an honest independent choice of the same string by two different students — become, from the mechanism's point of view, *the same facet*. A ``query_facet()`` handling one of them will hand back a pointer, ``static_cast`` will "succeed" in the sense of not crashing, and the caller will silently operate on an object through the wrong type's vtable. Nothing in the type system catches this; it is a runtime, likely intermittent, memory-safety bug. Unlike COM's GUIDs (128 bits, generated to make an accidental collision practically impossible) or Rust's compiler-derived ``TypeId``, there is no mechanism in TeachOS enforcing that two ``facet_id`` values with the same name really do refer to the same facet, or catching it when they do not.
+
+ The project has since narrowed, without closing, this risk by moving from bare, single-word identifiers (an earlier ``isa_claim``, ``block``) to a structured, dotted convention: a short category prefix, a role, and a specific name — ``sig.dev.isa`` and ``clm.drv.isa`` for the ISA identification pair, ``type.dev.bus`` for the structural bus facet, ``prot.bus.base`` for ``bus_protocol``, ``fs.spec.block``/``fs.spec.char`` for the two special-file facets. This is the same instinct behind COM's GUIDs and Java/OSGi's reverse-DNS package names: give every id enough structure that an *accidental* collision needs two authors to independently choose the same category, the same role, and the same specific name, rather than just the same common word. It is a real mitigation — but it is still string equality with no compiler-enforced uniqueness behind it, so a careless copy-paste of a full dotted id (rather than just a bare word) remains exactly as silent a failure as before.
* **The delegation chain is manual and unenforced.** Every ``query_facet()`` override that does not recognize a requested id must remember to fall through to its base class's ``query_facet()``. Forgetting this (easy to do — it compiles cleanly either way) silently makes every facet contributed by the base class unreachable through that subtype, with no compiler diagnostic and no crash — only a ``has_facet()`` that quietly returns false where it should not, discovered (if it is discovered at all) as a device that mysteriously never matches a driver it should have.
* **No enumeration or reflection.** There is no way to ask a ``device`` or a ``facet_registry::entry`` "what facets do you support" in general — only "do you support *this specific* facet", one id at a time. A caller must already know which tag to ask for. This is the direct cost of the expression-problem trade-off described above: the operation "list every facet a device has" is not something the mechanism was designed to answer, and adding it would need either an explicit per-type registration list (defeating the "device layer knows nothing about facets" property) or a hand-maintained catalogue kept separately from every facet definition, with the drift risk that implies.
* **Dispatch cost is linear in the number of facets a concrete type supports**, not constant. Each ``query_facet()`` override is a manual if/else-if chain; a type supporting five facets does up to five string-view comparisons per query, whereas a real vtable slot is one indirect call regardless of how many other virtual functions the type has. For device counts and facet counts in a teaching kernel this is immaterial, but it is a genuine, quantifiable difference from the polymorphism this mechanism deliberately avoids, worth naming rather than glossing over.
* **No interface-versioning discipline yet.** Changing the shape of an existing facet (adding a pure virtual method, changing a signature) breaks every type that implements it, exactly as with any C++ abstract base class — facet dispatch does nothing to soften this. COM's answer (never modify a shipped interface; mint a new GUID for a new shape, and let objects implement both old and new side by side) is a real, well-tested discipline for exactly this problem that TeachOS has not yet adopted. Today, changing a facet's shape means finding and updating every implementer by hand.
* **Inheritance- and composition-published facets are not equally discoverable.** As shown above, a composed facet (``ram_disk``'s ``block_node``) is invisible to ``device.facet<T>()`` and reachable only through the registry, while an inherited facet is reachable both ways. This asymmetry is intentional and documented here, but it is exactly the kind of thing a newcomer gets wrong once before internalizing it.
-* **Thread-safety is the caller's problem, not the mechanism's.** ``query_facet()`` itself performs no synchronization; a facet pointer obtained from a device is only as valid as whatever else is happening to that device concurrently (unbinding, teardown). ``facet_registry`` protects its own internal bookkeeping with a lock and hands back only ``weak_ptr``-backed handles for exactly this reason, but a raw ``T *`` obtained via ``device.facet<T>()`` carries no such protection on its own. The project's general locking primitive, ``kapi::tracked_mutex`` (``kapi/kapi/tracked_mutex.hpp``), is available for a caller that needs to hold a facet pointer across a window where the underlying device could otherwise be torn down; this brief does not attempt to restate the project's broader locking discipline.
+* **Thread-safety is the caller's problem, not the mechanism's.** ``query_facet()`` itself performs no synchronization; a facet pointer obtained from a device is only as valid as whatever else is happening to that device concurrently (unbinding, teardown). ``facet_registry`` protects its own internal bookkeeping with a lock and hands back only ``weak_ptr``-backed handles for exactly this reason, but a raw ``T *`` obtained via ``device.facet<T>()`` carries no such protection on its own. The project's general locking primitive, ``kapi::tracked_mutex`` (``kapi/kapi/tracked_mutex.hpp``), is available for a caller that needs to hold a facet pointer across a window where the underlying device could otherwise be torn down; this brief does not attempt to restate the project's broader locking discipline. See :doc:`tb0004-device-tree-ownership-and-registries` for what ``tracked_mutex`` does and does not guarantee, and for the ``weak_ptr``-based ownership discipline ``facet_registry`` relies on.
When (Not) to Reach for a Facet
-----------------------------------
@@ -428,3 +430,7 @@ References
``kernel/kernel/drivers/storage/ram_disk.cpp`` — the composed-facet example this brief quotes from directly.
``kapi/kapi/tracked_mutex.hpp`` — the locking primitive referenced in *Drawbacks and Limitations*, above.
+
+ :doc:`tb0004-device-tree-ownership-and-registries` — device ownership (``shared_ptr``/``weak_ptr``), the three registries a facet query can reach through, and ``tracked_mutex`` in full.
+
+ :doc:`../guides/device-drivers` — a practical, worked-example walkthrough of matching and binding, which is the other half of what a facet's ``id`` gets used for.
diff --git a/docs/briefs/tb0004-device-tree-ownership-and-registries.rst b/docs/briefs/tb0004-device-tree-ownership-and-registries.rst
new file mode 100644
index 00000000..b90d1063
--- /dev/null
+++ b/docs/briefs/tb0004-device-tree-ownership-and-registries.rst
@@ -0,0 +1,226 @@
+Technical Brief 0004: Device Tree Ownership and the Three Registries
+========================================================================
+
+:doc:`/briefs/tb0003-facet-based-capability-dispatch` covers how one device or driver answers "do you support capability X".
+The :doc:`/guides/device-drivers` guide covers how a driver gets matched and bound to a device.
+Neither asks a more basic pair of questions: **who keeps a device alive, and how does anything outside its own parent bus find it again?**
+
+Those two questions are what this brief answers: the ownership graph that decides when a device is actually destroyed, the locking primitive that protects it, and the three separate registries (``device_registry``, ``driver_registry``, ``facet_registry``) that each answer a different "find things" question over the same tree.
+
+The Ownership Graph
+-----------------------
+
+A ``bus`` owns its children strongly, whereas a child only ever points back at its parent weakly.
+This is the whole graph, and it is worth being precise about why it has to be exactly this shape.
+
+.. code-block:: cpp
+
+ // kapi/kapi/devices/bus.hpp
+ struct bus : device
+ {
+ // ...
+ private:
+ kstd::vector<kstd::shared_ptr<device>> m_devices{}; // strong: the bus owns its children
+ };
+
+ // kapi/kapi/devices/device.hpp
+ struct device : kstd::enable_shared_from_this<device>
+ {
+ // ...
+ private:
+ kstd::weak_ptr<bus> m_parent; // weak: a child never keeps its parent alive
+ kstd::weak_ptr<struct driver> m_driver{}; // weak: a device never keeps its bound driver alive
+ };
+
+``bus::add_child()`` sets the back-pointer and takes ownership in that order:
+
+.. code-block:: cpp
+
+ // kernel/kapi/devices/bus.cpp
+ auto bus::add_child(kstd::shared_ptr<device> const & child) -> void
+ {
+ {
+ auto guard = kstd::lock_guard{m_lock};
+ child->set_parent(kstd::static_pointer_cast<bus>(shared_from_this())); // weak back-edge
+ }
+
+ if (!kapi::devices::device_registry::get().add(child)) { /* ... */ }
+
+ auto attached = kstd::shared_ptr<device>{};
+ {
+ auto guard = kstd::lock_guard{m_lock};
+ attached = m_devices.emplace_back(std::move(child)); // strong forward-edge
+ }
+
+ attached->set_state(state::present);
+ driver_registry::get().device_attached(attached);
+ }
+
+A cycle of two ``shared_ptr``\ s never gets collected, as each side's reference count stays above zero forever, because each is the reason the other is never zero.
+If ``m_parent`` were a ``kstd::shared_ptr<bus>`` instead of a ``kstd::weak_ptr<bus>``, every subtree in the device tree would leak permanently the moment it was attached.
+The bus keeps its children alive, and every child would be keeping its own parent alive.
+``device::parent()`` and ``device::bound_driver()`` both call ``.lock()`` on their respective weak pointers precisely because neither edge is allowed to be a source of ownership.
+A device is only allowed to to *ask*, if the thing being (parent bus or driver) is still there:
+
+.. code-block:: cpp
+
+ // kernel/kapi/devices/device.cpp
+ auto device::parent() const -> kstd::shared_ptr<bus>
+ {
+ auto guard = kstd::lock_guard{m_lock};
+ return m_parent.lock();
+ }
+
+ auto device::bound_driver() const noexcept -> driver *
+ {
+ auto guard = kstd::lock_guard{m_lock};
+ return m_driver.lock().get();
+ }
+
+The same rule governs every registry below: all three hold devices (and, where relevant, drivers) by ``weak_ptr``, never ``shared_ptr``.
+A registry is an index, not an owner — a fact worth stating explicitly, because "the registry has a pointer to every device" reads, at a glance, like a reason a registry might need strong ownership, when it is exactly the opposite: an index that could keep its entries alive forever is not an index, it is a second, competing owner.
+
+``weak_ptr::lock()`` itself is safe to call while the object it refers to is concurrently being destroyed on another core.
+Thus, a ``lock()`` call racing a bus tearing down its last strong reference to a child gets one of two well-defined outcomes: a valid, fully-alive ``shared_ptr``, or an empty one.
+``lock()`` will never return a pointer to an object that is mid-destruction.
+
+``tracked_mutex``: What "Tracked" Actually Means
+------------------------------------------------------
+
+Every mutable field discussed so far — a device's own state, a bus's child list, each registry's own bookkeeping — is guarded by a ``kapi::tracked_mutex`` (``kapi/kapi/tracked_mutex.hpp``), not a plain spinlock. It is worth being precise about what "tracked" buys, because the name can suggest more than it delivers: this is not a general deadlock detector across multiple locks, or across cores waiting on each other. It solves one specific, real problem — a single core silently re-entering a lock it already holds — by recording which CPU currently owns the lock and refusing to let anything but that CPU release it:
+
+.. code-block:: cpp
+
+ struct tracked_mutex
+ {
+ //! @warning This function will panic if a recursive lock on a single core is detected!
+ auto lock() -> void;
+
+ //! @warning This function will panic if the core executing the call does not own the lock on this mutex!
+ auto unlock() -> void;
+
+ private:
+ std::atomic<kapi::cpu::id> m_owner{kapi::cpu::invalid_id};
+ };
+
+A plain spinlock would deadlock a core against itself silently and permanently (core 3 already holds the lock; core 3 tries to take it again; core 3 now spins forever waiting for a release that will never come, because the only core that could release it is itself, and it is busy spinning). ``tracked_mutex`` turns that into an immediate, diagnosable panic at the exact call site that re-entered the lock, rather than a hang discovered later by a watchdog or a confused bug report. It does **not** detect the more general two-lock deadlock (core A holds lock 1 and waits for lock 2; core B holds lock 2 and waits for lock 1) — that remains the caller's responsibility, governed by whatever lock-ordering discipline the surrounding code follows.
+
+The Three Registries
+------------------------
+
+The tree itself (parent/child pointers on ``bus``/``device``) only answers one question: "who are this bus's direct children". Everything else — "find the device named X anywhere in the tree", "who currently implements facet Y", "which driver, if any, wants this device" — goes through one of three purpose-built, independent registries, each a system-wide singleton reached through its own ``get()``.
+
+.. list-table::
+ :header-rows: 1
+
+ * - Registry
+ - Answers
+ - Keyed by
+ - Owns entries?
+ * - ``device_registry``
+ - "Is there a device named X, anywhere in the tree?"
+ - device tree-name (``kstd::string``)
+ - No — ``weak_ptr<device>``
+ * - ``driver_registry``
+ - "Try every registered driver against this device" / vice versa
+ - not keyed — a flat list, tried in full each time
+ - Yes — ``shared_ptr<driver>`` (drivers are not tree members; something has to own them)
+ * - ``facet_registry``
+ - "Which devices currently implement facet Y?" (:doc:`/briefs/tb0003-facet-based-capability-dispatch`)
+ - ``facet_id``
+ - No — ``weak_ptr<device>``
+
+``device_registry`` is the simplest of the three: a flat map from name to a weakly-held device, populated by ``bus::add_child()`` and pruned by ``bus::do_remove_child()``, with ``find()``/``all()`` as its read side:
+
+.. code-block:: cpp
+
+ // kapi/kapi/devices/device_registry.hpp
+ struct device_registry
+ {
+ auto add(kstd::shared_ptr<device> const & device) -> bool;
+ auto remove(device & device) -> bool;
+ [[nodiscard]] auto find(std::string_view name) const -> kstd::shared_ptr<device>;
+ [[nodiscard]] auto all() const -> kstd::vector<kstd::shared_ptr<device>>;
+ auto subscribe(kstd::weak_ptr<device_registry_observer> observer) -> void;
+ };
+
+``driver_registry`` looks similar on the surface (also a system-wide singleton with an ``add()``) but is not a lookup structure at all — it has no ``find()``, no name, no key. Its entire purpose is the matching algorithm covered in the driver guide (:doc:`/guides/device-drivers`); "which drivers exist" is never a question anything outside ``driver_registry`` itself needs to ask, so there is no API for it.
+
+``facet_registry`` sits in between: like ``device_registry`` it is a read-and-subscribe index over weakly-held devices, but its key is a capability (``facet_id``) rather than an identity (a name), and — a real, deliberate asymmetry worth noticing — it offers *two* different subscription shapes, not one:
+
+.. code-block:: cpp
+
+ // kapi/kapi/devices/facet_registry.hpp
+ auto subscribe(kstd::weak_ptr<facet_registry_observer> observer) -> void; // safe if the observer dies unregistered
+ auto subscribe(facet_registry_observer & observer) -> void; // observer must outlive its subscription
+ auto unsubscribe(facet_registry_observer & observer) -> void;
+
+``device_registry_observer`` only ever gets the first, safer shape. ``facet_registry`` additionally accepts a raw, must-outlive-or-else reference — useful for a long-lived, non-``shared_ptr``-managed subsystem (a singleton driver of drivers, say) that would rather not pay for a ``weak_ptr`` it knows it will never need, at the cost of undefined behavior if that assumption is ever wrong. Both registries prune expired weak subscriptions lazily, on the next notification pass, rather than eagerly.
+
+Why three registries, and not one generic one? This is worth asking directly, because :doc:`/briefs/tb0003-facet-based-capability-dispatch` makes the opposite argument for facets — one generic ``facet_registry`` rather than one bespoke registry per capability — and it would be reasonable to expect the same collapse here. It does not apply, for a structural reason: a generic facet is *interchangeable* with any other facet from the registry's point of view (the registry never inspects what a facet actually does, only that a ``facet_id`` names it) — that is exactly what let ``interface_registry``-turned-``facet_registry`` become one type. Identity-by-name, capability-by-facet, and driver-binding-state are not interchangeable in that sense: they answer structurally different questions, over different notions of "the same entry" (a name is unique per bus; a facet can be published under many different names by many different devices; a driver has no name-like key at all). Collapsing them would not remove duplicated bookkeeping the way unifying the facet registries did — it would conflate three genuinely different concerns back into one type, which is precisely the failure mode the whole facet mechanism exists to avoid in the first place.
+
+A Known Issue: Duplicate Names in ``device_registry::add()``
+------------------------------------------------------------------
+
+Tracing ``device_registry::add()`` for this brief turned up a real discrepancy between its documented contract and its current behavior, worth recording plainly rather than silently working around in the prose above.
+
+The header says: *"@return true if the device was registered successfully, false otherwise."* The one test that exists for this registry (``kernel/kapi/devices.tests.cpp``) is scoped even more narrowly — its own scenario name is *"the device registry holds devices weakly and prunes stale entries lazily"*, and it only exercises reusing a name whose previous registration has already **expired**. Neither promises that registering a second device under a name that is still **live** should succeed — the reasonable reading of "registered successfully, false otherwise" is that a live name collision is exactly the "otherwise" case.
+
+The implementation does not do that:
+
+.. code-block:: cpp
+
+ // kernel/kapi/devices/device_registry.cpp
+ auto found = m_devices.find(device->name());
+ if (found != m_devices.end())
+ {
+ if (!found->second.expired())
+ {
+ added = false;
+ }
+
+ found->second = device; // <-- unconditional, regardless of the branch above
+ added = true; // <-- unconditional, overwriting the false set two lines up
+ }
+ else
+ {
+ added = m_devices.emplace(device->name(), device).second;
+ }
+
+The ``added = false;`` inside the ``!expired()`` branch is immediately overwritten by the unconditional ``found->second = device; added = true;`` that follows it, regardless of which branch ran. As written, registering a second, live device under a name that is already registered to a different, still-live device always succeeds, silently replaces the registry's entry, and returns ``true`` — the live-collision rejection the surrounding code visibly intends (the ``expired()`` check would otherwise have no purpose at all) never actually triggers. This is not exercised by any existing test, since the one test that touches name reuse only covers the expired case.
+
+This looks like an unintentional gap between intent and implementation rather than a deliberate design choice, and is flagged here rather than fixed silently, since fixing it changes observable behavior (a second ``add_child()`` under a colliding, still-live name would start returning ``false`` and, per ``bus::add_child()``'s own panic-on-registration-failure path, would newly ``panic`` instead of silently proceeding) — a decision worth making deliberately rather than as a side effect of writing this brief.
+
+Parallels in Other Systems
+------------------------------
+
+**Linux's device/driver-core registries** are the closest relative, and split along a similar seam: ``/sys`` (backed by ``struct kobject``/``kset``) is Linux's name-addressable device index — the rough analogue of ``device_registry`` — while each ``struct bus_type`` keeps its own driver list, matched against devices exactly where ``driver_registry`` sits here. Linux additionally layers ``struct class`` on top for a capability-flavored view (all block devices, regardless of bus) — closer in spirit to ``facet_registry`` than to either of the other two, though implemented as its own bespoke mechanism rather than TeachOS's one generic capability index (see the Linux comparison in :doc:`/briefs/tb0003-facet-based-capability-dispatch` for why that unification doesn't have a direct Linux equivalent).
+
+**GObject's weak-reference bookkeeping** (``g_object_add_weak_pointer()``/``g_object_weak_ref()``) is a close parallel to the pruning behavior of ``device_registry``'s and ``facet_registry``'s observer lists: a callback registered against an object's lifetime that is automatically, safely skipped once the object is gone, rather than left to dereference a dangling pointer — the same guarantee ``kstd::weak_ptr``-based subscription buys here, in a garbage-collected-adjacent language that still has to solve the same problem for non-refcounted or externally-owned objects.
+
+**COM's ownership discipline** (``AddRef``/``Release``, introduced in :doc:`/briefs/tb0003-facet-based-capability-dispatch` for its ``QueryInterface`` parallel) is the reason the weak-back-edge argument above should feel familiar: COM objects that hold pointers to each other in a cycle have exactly the same leak problem ``shared_ptr`` cycles do, and COM's answer — one side of any potential cycle must not hold a counted reference — is the same rule TeachOS enforces structurally by typing ``m_parent`` and ``m_driver`` as ``weak_ptr`` rather than leaving it to convention.
+
+Benefits
+-----------
+
+* **The ownership rule is enforced by the type system, not by convention.** ``m_parent`` and ``m_driver`` being ``weak_ptr`` rather than ``shared_ptr`` means a cycle-introducing bug (accidentally storing a strong parent reference somewhere) shows up as a type error or a deliberate ``.lock()`` call, not as a leak discovered by watching memory usage climb.
+* **Every registry is provably incapable of keeping a device alive past its real lifetime.** Because all three hold only ``weak_ptr<device>``, a device that is genuinely unreferenced everywhere else is destroyed on schedule regardless of how many registries still have a stale entry for it — the entry simply resolves to ``nullptr`` on the next lookup.
+* **``tracked_mutex`` turns one whole class of concurrency bug into an immediate, attributable panic.** A same-core recursive lock is caught the moment it happens, at the exact call site responsible, rather than manifesting as an unexplained hang discovered much later.
+* **Splitting identity, capability, and binding into three registries keeps each one's contract simple.** ``device_registry`` never has to reason about facets; ``facet_registry`` never has to reason about driver-binding state; ``driver_registry`` never has to reason about names. Each type is small enough to read end to end.
+
+Drawbacks and Limitations
+-----------------------------
+
+* **The duplicate-name gap above is a real, live correctness issue**, not a hypothetical one — a driver or bus author who assumes ``device_registry::add()`` rejects a name collision (as its own documentation states, and as the one existing test's scenario title implies) is currently wrong, silently, with no test catching it either way.
+* **``device_registry`` has no dedicated test file at all** (``kernel/kapi/devices.tests.cpp`` exercises it only indirectly, through a handful of scenarios) — the exact kind of gap that let the issue above go unnoticed, and a real argument for giving this registry the same direct, scenario-driven test coverage ``facet_registry`` and ``driver_registry`` already have.
+* **Three registries is three places to remember to check**, not one. A subsystem that wants "everything currently known about a device" has to separately query ``device_registry`` (does it exist, by name), ``facet_registry`` (what can it do), and ``device.bound_driver()`` (who's driving it) — there is no single call that answers all three at once, by design, but it is still a real seam a newcomer has to learn rather than discover from one type's API surface.
+* **``tracked_mutex``'s name promises more than it delivers if read quickly.** "Tracked" means same-core re-entrancy detection specifically, not deadlock detection generally — a two-lock, two-core deadlock is exactly as possible here as with a plain spinlock, and nothing in the type's name or its panic messages says so.
+* **Neither ``device_registry`` nor ``facet_registry`` notifies observers atomically with the mutation that triggered them.** Both take a lock, mutate, release the lock, then take it again separately to snapshot the observer list before calling out to each one unlocked — deliberately, so an observer's callback never runs while the registry's own lock is held (avoiding a lock-order problem an observer callback that re-enters the registry would otherwise create) — but it does mean a second thread could observe the registry's new state before a given observer has been notified of the change that produced it.
+
+Checklist
+------------
+
+* A back-edge (child → parent, device → bound driver, any registry → device) is always ``weak_ptr``. If you find yourself typing ``shared_ptr`` for one of these, stop — it is almost certainly the wrong direction.
+* Query a ``weak_ptr`` with ``.lock()`` and check the result; never assume it is still valid because you "just" saw it alive.
+* Pick the registry that matches the actual question: a name → ``device_registry``; a capability → ``facet_registry`` (:doc:`/briefs/tb0003-facet-based-capability-dispatch`); "should a driver claim this" → the matching machinery in :doc:`/guides/device-drivers`, not a registry lookup at all.
+* Don't assume ``device_registry::add()`` rejects a live name collision today — see *A Known Issue*, above — until it is either fixed or its documented contract is corrected to match what it actually does.
diff --git a/docs/briefs/tb0005-device-resources.rst b/docs/briefs/tb0005-device-resources.rst
new file mode 100644
index 00000000..5132ff6f
--- /dev/null
+++ b/docs/briefs/tb0005-device-resources.rst
@@ -0,0 +1,241 @@
+Technical Brief 0005: The Device Resource Model
+====================================================
+
+*Technical Brief 0003* (:doc:`/briefs/tb0003-facet-based-capability-dispatch`) covers how a device or driver answers "do you support capability X". *Technical Brief 0004* (:doc:`/briefs/tb0004-device-tree-ownership-and-registries`) covers who owns a device and how anything finds it again. Neither addresses a third, more mundane question every real driver has to answer: **which I/O port, memory range, interrupt line, or DMA channel does this specific device instance actually use?**
+
+That is what a ``kapi::devices::resource`` answers. This brief covers its shape, why resources are assigned as data rather than hard-coded into a driver, and where that design does and does not protect a driver author from mistakes.
+
+The Problem This Replaces
+------------------------------
+
+A naive driver hard-codes its I/O addresses:
+
+.. code-block:: cpp
+
+ // what a driver would look like without a resource model
+ constexpr auto pit_port = std::uint16_t{0x40};
+ constexpr auto pit_irq = std::uint32_t{0};
+
+This works exactly once, for exactly one bus topology, on exactly one machine — which is a description of most real hardware only by accident. The i8253 PIT lives at port ``0x40`` on every PC-compatible ISA bus, so the constant *happens* to be correct almost everywhere, but the fact that it is a compile-time constant baked into ``pit.cpp`` is still a latent bug: nothing about the PIT driver itself requires the ISA bus, that specific port range, or that specific IRQ. A second bus that also carries a PIT-compatible timer at a different address — or the same test running two PITs in a simulator for teaching purposes — has no way to tell the driver about it, short of editing the driver's source.
+
+The fix is to separate *what a device needs* (a driver-side concern — "some I/O ports and an IRQ") from *what a device has been given* (a bus-side concern — "these specific ports, this specific IRQ, discovered or configured however this bus discovers or configures things"), and to pass the second across a narrow, typed, runtime interface rather than a compile-time constant. That interface is ``kapi::devices::resource``.
+
+The Core Mechanism
+-----------------------
+
+A resource is one of exactly four kinds, named by ``resource_type``:
+
+.. code-block:: cpp
+
+ // kapi/kapi/devices/resource.hpp
+ enum struct resource_type
+ {
+ mmio, // a range of the memory-mapped I/O address space
+ port, // a range of I/O ports
+ irq, // an interrupt line
+ dma, // a direct memory access channel
+ };
+
+Each kind has its own small value type, tagged with its own ``resource_type`` as a ``static`` member:
+
+.. code-block:: cpp
+
+ struct mmio_range
+ {
+ constexpr auto static type = resource_type::mmio;
+ kapi::memory::physical_address start;
+ kstd::bytes size;
+ };
+
+ struct io_port
+ {
+ constexpr auto static type = resource_type::port;
+ std::uint16_t start;
+ std::size_t count;
+ };
+
+ struct interrupt_line
+ {
+ constexpr auto static type = resource_type::irq;
+ std::size_t number;
+ };
+
+ struct dma_channel
+ {
+ constexpr auto static type = resource_type::dma;
+ std::size_t number;
+ };
+
+``resource`` itself is a tagged union over the four — a discriminant (``resource_type type``) alongside an anonymous ``union`` holding exactly one of the four value types at a time, with one converting constructor per kind:
+
+.. code-block:: cpp
+
+ struct resource
+ {
+ explicit resource(mmio_range range);
+ explicit resource(io_port port);
+ explicit resource(interrupt_line irq);
+ explicit resource(dma_channel channel);
+
+ template<resource_type Type>
+ [[nodiscard]] constexpr auto get() const
+ {
+ if (Type != this->type)
+ {
+ system::panic("[OS:DEV] Invalid resource access! {} != {}", Type, type);
+ }
+ // ... returns the matching alternative
+ }
+
+ resource_type type;
+
+ private:
+ union { mmio_range range; io_port port; interrupt_line irq; dma_channel channel; } m_value;
+ };
+
+A device carries a plain ``kstd::vector<resource>`` — an unordered bag, not a struct with four named slots — and exposes it through a narrow API on ``device`` (``kapi/kapi/devices/device.hpp``):
+
+.. code-block:: cpp
+
+ //! Assign resources to this device.
+ auto set_resources(kstd::vector<resource> resources) -> void;
+
+ //! Get a specific resource assigned to this device.
+ //!
+ //! @param type The type of the resource to request.
+ //! @param index The type-relative index of the resource to request.
+ [[nodiscard]] auto request_resource(resource_type type, std::size_t index = 0) const -> kstd::result<resource>;
+
+ //! Get a specific resource assigned to this device.
+ template<resource_type Type>
+ [[nodiscard]] auto request_resource(std::size_t index = 0) const
+ {
+ return request_resource(Type, index).transform([](auto resource) { return resource.template get<Type>(); });
+ }
+
+"Type-relative index" is precise and worth reading twice: ``index`` counts only among resources of the requested ``type``, not among all of a device's resources. A device with two I/O port ranges and one IRQ line addresses its second port range as ``request_resource<resource_type::port>(1)`` and its (only) IRQ as ``request_resource<resource_type::irq>(0)`` — the port ranges and the IRQ never compete for the same index space, regardless of the order they were assigned in.
+
+Assignment happens on the bus side, before the device is even attached to the tree, and consumption happens on the driver side, inside ``probe()``. The complete real example is the ISA PIT (``arch/x86_64/arch/devices/init.cpp``):
+
+.. code-block:: cpp
+
+ auto pit_device = kstd::make_shared<pit>();
+ pit_device->set_resources({
+ kapi::devices::resource{kapi::devices::io_port{.start = 0x40, .count = 4}},
+ kapi::devices::resource{kapi::devices::interrupt_line{.number = 0}},
+ });
+ isa_bus->add_child(pit_device);
+
+and the driver that later requests them back (``arch/x86_64/arch/drivers/pit.cpp``):
+
+.. code-block:: cpp
+
+ auto pit::probe(kapi::devices::device & device) -> kstd::result<void>
+ {
+ auto port = device.request_resource<kapi::devices::resource_type::port>();
+ if (!port)
+ {
+ return kstd::failure(port.error());
+ }
+
+ auto irq = device.request_resource<kapi::devices::resource_type::irq>();
+ if (!irq)
+ {
+ return kstd::failure(irq.error());
+ }
+
+ auto base_port = port->start;
+ auto irq_number = irq->number;
+ // ... programs the hardware using base_port and irq_number, never a literal
+ return kstd::success();
+ }
+
+Nothing in ``pit.cpp`` mentions ``0x40`` or ``0``. The driver asks for "my port range" and "my IRQ", by type, and gets back whatever the bus that actually discovered or configured this specific device instance decided to assign. The same driver binary would work unmodified against a PIT wired to a different port on a different (hypothetical) bus.
+
+Why a Panicking ``get<Type>()``, Not a Recoverable One
+-----------------------------------------------------------
+
+``resource::get<Type>()`` calls ``system::panic()`` if the stored discriminant does not match the requested ``Type`` — a hard stop, not an error code. This is a deliberate asymmetry with facet dispatch (:doc:`/briefs/tb0003-facet-based-capability-dispatch`), where an unsupported capability is a routine, expected, recoverable ``nullptr``. The two situations are not the same kind of "wrong": whether a device *has* a given facet is discovered dynamically and is often expected to be false (most devices do not support most facets) — a recoverable result is the only sane API. Whether a caller who already holds a ``resource`` object of a *known, compile-time* ``resource_type`` asks ``get<Type>()`` for the *matching* ``Type`` is not a discovery at all — the caller wrote the mismatched ``Type`` themselves, at the same call site, in the same function. Reaching that panic requires a programmer to type ``resource{io_port{...}}.get<resource_type::irq>()`` or the moral equivalent — a bug in the caller's own code, not a fact about the hardware. ``request_resource(type, index)`` is where the genuinely recoverable case lives instead: "does this device have a port resource at all" is answered by ``kstd::result<resource>``, exactly like an unsupported facet, and the templated convenience overload only reaches ``get<Type>()`` after that recoverable check has already succeeded — so ``get<Type>()``'s ``Type`` and the request's ``Type`` are, by construction, always the same value on that path.
+
+Two Real Fixes, Two Real Lessons
+--------------------------------------
+
+The resource model's git history is short but instructive — two consecutive fix commits, both from the same afternoon, both worth reading directly rather than summarizing away.
+
+The type initially shipped with a fifth, sentinel ``resource_type::invalid`` and a matching ``invalid_resource`` union member, and ``resource::type`` defaulted to it (``resource_type type{resource_type::invalid};``). A later commit (``1121cb81``, "kapi: fix device resources") removed both entirely, and changed ``get<Type>()`` from silently falling through the ``if constexpr`` chain on a mismatch to explicitly panicking. The sentinel added a fifth state that every ``switch`` over ``resource_type`` had to account for, for a case that should have been structurally impossible — every ``resource`` is constructed through one of the four typed, converting constructors, so there was never a code path that legitimately produced an ``invalid`` resource; the sentinel exists only to be defended against, not to be reached. Removing it is a small, telling example of a broader principle: a "just in case" default state that the type's own constructors can never actually produce is not defensive programming, it is an extra case for every future ``switch`` to handle correctly, forever, in exchange for catching nothing real.
+
+The other fix (``dca83530``, "kapi: fix device resource lookup", landing slightly earlier the same day) corrected ``request_resource``'s actual lookup logic. The original implementation enumerated the device's *entire* resource vector first and then filtered by type:
+
+.. code-block:: cpp
+
+ // before dca83530 — index counted across ALL resources, not just matching ones
+ auto numbered_resources = std::views::enumerate(m_resources);
+ auto found = std::ranges::find_if(numbered_resources, [&](auto entry) {
+ auto const & [number, resource] = entry;
+ return resource.type == type && static_cast<std::size_t>(number) == index;
+ });
+
+against the corrected version, which filters first and enumerates the *filtered* view:
+
+.. code-block:: cpp
+
+ // after dca83530 — index counts only among resources of the requested type
+ auto filtered = std::views::filter(m_resources, [&](auto e) { return e.type == type; });
+ auto numbered_resources = std::views::enumerate(filtered);
+ auto found = std::ranges::find_if(
+ numbered_resources, [&](auto entry) { return static_cast<std::size_t>(std::get<0>(entry)) == index; });
+
+The difference is exactly the "type-relative index" the header comment already promised: with the original ordering, a device with one port range followed by one IRQ could never successfully look up that IRQ as index ``0`` (it sits at position ``1`` in the unfiltered vector), silently contradicting its own documentation. The fix is a two-line reordering of a ``std::ranges`` pipeline, and a good illustration of how easy it is for a filter-then-number operation to be written number-then-filter by mistake — the types all still line up, the code still compiles, and the bug only shows itself as a request that should succeed returning "not found" for specific, order-dependent combinations of resource assignments.
+
+Benefits
+-----------
+
+* **A driver never hard-codes an address that belongs to the bus.** The PIT driver above has no ISA-specific or PC-specific constant anywhere in it; every address comes from ``request_resource()``, sourced from whatever assigned it.
+* **The tagged union has no wasted state.** Since the ``invalid`` sentinel was removed, every ``resource`` that exists represents a real, fully-formed value of one of the four kinds — there is no "resource in an unset state" to defend against at every use site.
+* **Type-relative indexing composes cleanly with multiplicity.** A device with several resources of the same kind (two MMIO ranges, three DMA channels) addresses each one independently of how many resources of *other* kinds it also happens to carry, and independently of assignment order.
+* **The recoverable/panicking split matches where the actual risk is.** "Does this device have a port resource" is a real question about hardware and gets a real, checkable answer; "did I ask a ``resource`` I already extracted for the wrong compile-time type" is a caller bug and gets a panic that points straight at the mistake.
+
+Drawbacks and Limitations
+------------------------------
+
+Stated as plainly as the benefits above.
+
+* **No overlap or conflict detection, anywhere.** ``set_resources()`` stores whatever ``kstd::vector<resource>`` it is given, without checking it against any other device's resources — two devices can be assigned overlapping I/O port ranges, or the same IRQ number, and nothing in the resource model itself will notice, let alone refuse. Linux's ``struct resource`` tree (``request_region()``/``request_mem_region()``) does exactly this kind of conflict detection as its central job — a real point of contrast, not just a missing nice-to-have, since it is the feature that lets Linux tell a user *which* two drivers are fighting over the same port range instead of silently corrupting whichever one loses the race.
+* **No automatic release on unbind.** A resource assigned via ``set_resources()`` stays assigned for the device's lifetime; there is no bus-side bookkeeping that reclaims a port range or IRQ when a driver unbinds, and no equivalent of ``release_region()``. Reassigning a resource elsewhere after a device is torn down is a manual, out-of-band exercise for whatever code manages that bus.
+* **The bag-of-resources shape has no schema.** A ``kstd::vector<resource>`` says nothing about how many resources of which kinds a given driver actually expects; ``request_resource<resource_type::port>(2)`` failing because the device only has two port ranges (indices 0 and 1) looks, from the driver's perspective, identical to a bus author who simply forgot to assign the third one. Nothing catches this earlier than the same ``probe()`` failure a genuine hardware absence would produce.
+* **``request_resource()`` returns a snapshot copy, not a live view.** Because ``resource`` is a small value type returned by value inside a ``kstd::result``, nothing prevents ``set_resources()`` from being called again on an already-bound device out from under a driver that cached an earlier ``request_resource()`` result — the resource model has no notion of "resources are fixed once a driver is bound" to enforce.
+* **The panic in ``get<Type>()`` is only as good as the caller already knowing ``Type``.** It protects against a literal typo at a ``get<>()`` call site; it does nothing for a driver that requests the *right* type but the *wrong* index, or one that never checks whether ``request_resource()`` returned an error at all before dereferencing the result.
+
+Parallels in Other Systems
+-------------------------------
+
+**ACPI's** ``_CRS`` (Current Resource Settings) object is the closest firmware-level analogue: a per-device, byte-encoded list of the same four broad categories — memory ranges, I/O port ranges, interrupts, and DMA channels — that the ACPI-aware OS parses and hands to the matching driver, discovered and assigned by firmware rather than hard-coded anywhere in the OS [1]_. The shape (an unordered bag of typed, per-device resource descriptors, consumed by whatever driver later binds) is essentially the same idea TeachOS's ``resource`` vector implements in miniature.
+
+**Devicetree's** ``reg``, ``interrupts``, and (on platforms that use it) ``dma-channels`` properties play the identical role for firmware-described, non-discoverable buses common in embedded systems: a node in the device tree lists the address ranges and interrupt lines it owns, and the OS's platform bus code walks the tree assigning them to matching drivers, the same enumerate-then-assign shape as ``init_legacy_devices()`` walking the ISA bus above [2]_.
+
+**Linux's** ``struct resource`` is the mechanism this brief has already contrasted directly: a tree (not merely a list) of address ranges, with ``request_region()``/``request_mem_region()`` performing exactly the overlap detection TeachOS's model omits, returning failure if the requested range intersects one already claimed [3]_. Reading Linux's resource code after this brief is a natural next step precisely because the shape is so recognizable and the one place it diverges — a global, conflict-checked tree of *all* address space, not just per-device bags — is worth understanding as a deliberate simplification here, not an oversight.
+
+**FreeBSD's** ``rman`` (resource manager) generalizes the same idea one step further: an ``rman`` instance owns a numeric range (I/O ports, memory, IRQs) and hands out sub-ranges via ``rman_reserve_resource()``, tracking which sub-ranges are currently allocated so a second reservation over an already-claimed range fails — the same conflict-detection property Linux's tree gives, implemented as an explicit, reusable allocator rather than a single global tree [4]_.
+
+References
+-------------
+
+.. [1] UEFI Forum, Inc., *Advanced Configuration and Power Interface (ACPI) Specification*, section on "Device Configuration" and the ``_CRS``/``_PRS``/``_SRS`` control methods. `Online <https://uefi.org/specifications>`_.
+
+.. [2] devicetree.org, *Devicetree Specification*, sections on the ``reg`` and ``interrupts`` properties. `Online <https://www.devicetree.org/specifications/>`_.
+
+.. [3] Corbet, J., Rubini, A., and Kroah-Hartman, G., *Linux Device Drivers*, 3rd ed., O'Reilly, 2005, chapter 9, "Communicating with Hardware" (I/O port and memory region allocation, ``request_region()``). See also the current kernel source, ``kernel/resource.c`` and ``include/linux/ioport.h``.
+
+.. [4] McKusick, M. K., Neville-Neil, G. V., and Watson, R. N. M., *The Design and Implementation of the FreeBSD Operating System*, 2nd ed., Addison-Wesley, 2014, chapter on bus and device management (``newbus``, ``rman(9)``). See also the FreeBSD ``rman(9)`` manual page. `Online <https://man.freebsd.org/cgi/man.cgi?query=rman&sektion=9>`_.
+
+.. seealso::
+
+ ``kapi/kapi/devices/resource.hpp`` and ``kernel/kapi/devices/device.cpp`` (``request_resource()``/``set_resources()``) — the mechanism this brief quotes from directly.
+
+ ``arch/x86_64/arch/devices/init.cpp`` and ``arch/x86_64/arch/drivers/pit.{hpp,cpp}`` — the complete assignment-and-consumption example this brief walks through.
+
+ :doc:`tb0003-facet-based-capability-dispatch` — the contrasting design for a genuinely dynamic, recoverable capability query, and why resources deliberately do not follow it.
+
+ :doc:`tb0004-device-tree-ownership-and-registries` — resources live on the same ``device`` object whose ownership and locking that brief covers; ``device::request_resource()`` takes the same ``tracked_mutex`` guard described there.
+
+ :doc:`../guides/device-drivers` — resources in practical use, inside a real ``probe()``.
diff --git a/docs/briefs/tb0006-compile-time-driver-self-registration.rst b/docs/briefs/tb0006-compile-time-driver-self-registration.rst
new file mode 100644
index 00000000..9c6293a0
--- /dev/null
+++ b/docs/briefs/tb0006-compile-time-driver-self-registration.rst
@@ -0,0 +1,326 @@
+Technical Brief 0006: Compile-Time Driver Self-Registration
+===========================================================
+
+:doc:`/briefs/tb0004-device-tree-ownership-and-registries` covers ``driver_registry`` as one of the three registries built into the device model, and notes in passing that it has no lookup API — only a flat list, tried in full against every device.
+This brief covers a question that document deliberately leaves open: **how does anything get into that list in the first place, given that no code anywhere calls a function named "register all drivers"?**
+
+The answer is a compile-time mechanism shared, in slightly different clothing, by two otherwise-unrelated parts of the kernel: device drivers (``kapi::devices::kernel_driver``/``platform_driver``) and filesystem drivers (``kernel::vfs::driver_module``).
+A driver's own ``.cpp`` file defined a small object and the build's linker collects a pointer to it into a dedicated, named section of the final kernel image.
+Afterwards, an explicit walk at boot turns that section into real, running driver instances.
+No central list exists anywhere in the source tree, and no constructor runs before ``main()`` to build one at runtime.
+
+The Problem This Replaces
+-------------------------
+
+An obvious implementation for the central device driver list, is a single, hand-maintained function, somewhere in ``kernel/kernel/drivers/init.cpp``.
+Such a function might look like the example below:
+
+.. code-block:: cpp
+
+ auto init() -> void
+ {
+ driver_registry::get().add(kstd::make_shared<pit_driver>());
+ driver_registry::get().add(kstd::make_shared<null_driver>());
+ driver_registry::get().add(kstd::make_shared<zero_driver>());
+ // ... one line per driver, forever
+ }
+
+This has two real costs, aside of aesthetics.
+First, it is a synchronization problem disguised as a list: every new driver requires a matching edit to a file its author may not think to touch.
+The failure mode of forgetting such an edit is a driver that compiles and links fine, but never gets bound to any device.
+This failure is silent and therefore difficult to debug, since the cause of the issue lies in a physically distinct file from the driver itself.
+Second, it is a dependency-direction problem: ``kernel/kernel/drivers/init.cpp`` would need to ``#include`` the header of every driver in the tree.
+This means that a change to any single driver's public interface touches a file that has no other reason to know that driver exists.
+Both problems get worse, not better, as the driver count grows.
+This stands in exact opposition to a mechanism whose whole job is to make adding a driver a local, self-contained change.
+
+The Core Mechanism
+------------------
+
+A driver announces itself with a small, private ``driver_descriptor`` type derived from ``kapi::devices::driver_descriptor``.
+This descriptor is instantiated directly within the main driver implementation file an automatically placed in a custom linker section.
+A complete, real example is the pseudo bus's ``null`` driver (``kernel/kernel/drivers/pseudo/null.cpp``):
+
+.. code-block:: cpp
+
+ namespace
+ {
+ struct descriptor final : kapi::devices::driver_descriptor
+ {
+ [[nodiscard]] auto name() const noexcept -> std::string_view override { return "dev_null"; }
+ [[nodiscard]] auto make_instance() const -> kstd::shared_ptr<kapi::devices::driver> override
+ {
+ return kstd::make_shared<null>();
+ }
+ };
+
+ [[gnu::used]]
+ constexpr auto registration = kapi::devices::kernel_driver<descriptor>{};
+ } // namespace
+
+``driver_descriptor`` (``kapi/kapi/devices/driver_registry.hpp``) exposes the minimal interface system this needs: a diagnostic name and a factory function that produces the real driver instance on demand.
+
+.. code-block:: cpp
+
+ struct driver_descriptor
+ {
+ virtual ~driver_descriptor() = default;
+ [[nodiscard]] virtual auto name() const noexcept -> std::string_view = 0;
+ [[nodiscard]] virtual auto make_instance() const -> kstd::shared_ptr<driver> = 0;
+ };
+
+The registration mechanism itself relies on instances of the type ``kernel_driver<Type>``.
+This class template has no member functions at all, and only two ``static`` data members:
+
+.. code-block:: cpp
+
+ template<typename Type>
+ struct kernel_driver
+ {
+ constexpr auto static instance = Type{};
+ [[using gnu: section("kernel_drivers"), used, visibility("hidden")]] constexpr auto static pointer{
+ kstd::make_observer<kapi::devices::driver_descriptor const>(&instance),
+ };
+ };
+
+An identical class template with a different name (``platform_driver``) exists for architecture specific drivers.
+
+The ``instance`` member is a instance of the driver's descriptor type that is constructed at compile-time.
+They ``pointer`` member points to that instance and is placed into a named section during compilation and linking.
+Those named sections (one for ``kernel_drivers`` and one for ``platform_drivers``) contain only a contiguous array of these pointers, one per driver.
+Each pointer is contributed independently by every ``.cpp`` file that defines an instance of ``kernel_driver`` or ``platform_driver``.
+
+The linker script (``arch/x86_64/scripts/kernel.ld``) reserves that region explicitly and exposes its bounds as ordinary symbols:
+
+.. code-block:: text
+
+ .kernel_rodata ALIGN(4K) : AT (ADDR (.kernel_rodata) - TEACHOS_VMA)
+ {
+ /* other read-only data sections go here ... */
+
+ /* Kernel driver factories */
+ PROVIDE(__start_kernel_drivers = .);
+ KEEP(*(kernel_drivers));
+ PROVIDE(__stop_kernel_drivers = .);
+
+ /* Platform driver factories */
+ PROVIDE(__start_platform_drivers = .);
+ KEEP(*(platform_drivers));
+ PROVIDE(__stop_platform_drivers = .);
+ } :kernel_rodata
+
+Every ``kernel_driver<SomeDescriptor>::pointer`` from every translation unit in the whole kernel image ends up contiguous in memory and is bracketed by ``__start_kernel_drivers`` and ``__stop_kernel_drivers`` — regardless of which ``.cpp`` file defined it, and without that file needing to be named anywhere else.
+The ``KEEP()`` linker instruction ensures the linker does not evict these pointers, despite them not being referenced anywhere.
+
+C++ code recovers those bounds as ordinary ``extern`` declarations and walks the range as a ``std::span`` (``kernel/kernel/drivers/init.cpp``):
+
+.. code-block:: cpp
+
+ extern "C"
+ {
+ extern kstd::observer_ptr<kapi::devices::driver_descriptor> const __start_kernel_drivers;
+ extern kstd::observer_ptr<kapi::devices::driver_descriptor> const __stop_kernel_drivers;
+ }
+
+ auto init() -> void
+ {
+ kapi::devices::driver_registry::init();
+ auto & registry = kapi::devices::driver_registry::get();
+
+ auto descriptors = std::span{&__start_kernel_drivers, &__stop_kernel_drivers} |
+ std::views::filter([](auto p) { return p != nullptr; });
+
+ for (auto driver : descriptors)
+ {
+ auto instance = driver->make_instance();
+ kstd::println("[OS:DRV] registering driver '{}' ({})", instance->name(), driver->name());
+ registry.add(std::move(instance));
+ }
+ }
+
+``platform_drivers`` is processed the same way, by ``kapi::devices::init_platform_drivers()``.
+
+Why This Needs No Constructor to Run
+------------------------------------
+
+The detail that makes this mechanism fit a freestanding, no-exceptions kernel, is that none of the code above executes before ``main()``.
+``kernel_driver<Type>::instance`` and ``::pointer`` are both ``constexpr``, requiring the compiler to fully construct their values at compile time.
+This includes resolution of the vtable pointer that allows access to ``instance`` via a ``driver_descriptor const &`` through ``pointer``.
+A C++ object's vtable pointer is not "set" by running code.
+Rather it is baked into the object's storage as a link-time relocation, the same way a global integer's initial value is.
+The upshot is that the entire ``kernel_driver<descriptor>{}`` object is present, complete, and correct in the compiled binary before anything has run at all.
+Thus, the boot-time "walk" in the shown above merely performs pure read operations.
+
+This is a genuine alternative to a generally more familiar C++ self-registration idiom: a global object whose *constructor* calls a registration function.
+This mechanism relies on the platform's dynamic-initialization machinery to run every one of these constructors at some point in time before user code executes.
+That idiom works in many situations, but it depends on the ordering and even existence of dynamic initialization being available and predictable.
+This is precisely the kind of platform machinery a freestanding, exceptions-free kernel is written to need as little of as possible, not least because it would invoke the "static initialization order fiasco".
+The kernel's implementations sidesteps the initialization order questions, since there is not order to reason about.
+All data is constructed during compile time and linked in the final image.
+
+Two-Phase Registration: Descriptor Now, Instance Later
+------------------------------------------------------
+
+The split between ``driver_descriptor`` and the actual ``driver`` object ``make_instance()`` produces is deliberate.
+It buys control over *when* a driver's constructor actually runs.
+A driver's constructor may need to allocate dynamic memory, but the kernel heap is not usable until ``kapi::memory::init()``/``kernel::memory::init_heap()`` have run.
+Because ``make_instance()`` is only ever called from inside the explicit ``kernel::drivers::init()``/``init_platform_drivers()`` walks, placed by hand at a specific point in ``kernel::main()`` after memory is ready, a driver author gets a real heap-allocating constructor without ever having to reason about whether it is safe to run yet.
+
+Three Independent Instances of the Same Idiom
+---------------------------------------------
+
+This linker-based registration idiom is used in multiple places within the kernel, including:
+
+* ``kapi::devices::kernel_driver<Type>`` → the ``kernel_drivers`` section, walked by ``kernel::drivers::init()``.
+* ``kapi::devices::platform_driver<Type>`` → the ``platform_drivers`` section, walked by ``kapi::devices::init_platform_drivers()``.
+* ``kernel::vfs::driver_module<Type>`` → the ``filesystem_drivers`` section, walked by ``kernel::vfs::driver_registry::init()``.
+
+The filesystem version follows the same general shape, using its own template, in its own namespace, with its own ``driver_descriptor`` type:
+
+.. code-block:: cpp
+
+ namespace
+ {
+ struct descriptor final : kernel::vfs::driver_descriptor
+ {
+ [[nodiscard]] auto name() const noexcept -> std::string_view override { return "ext2"; }
+ [[nodiscard]] auto make_instance() const -> kernel::vfs::filesystem::filesystem_ptr override
+ {
+ return kstd::make_shared<kernel::filesystems::ext2::filesystem>();
+ }
+ };
+
+ [[gnu::used]]
+ constexpr auto registration = kernel::vfs::driver_module<descriptor>{};
+ } // namespace
+
+The duplication is worth being honest about (see *Drawbacks*, below).
+``kernel::vfs::driver_registry::add()`` stores drivers in a ``kstd::flat_map<std::string_view, ...>`` and adds via ``m_drivers.emplace(name, driver)``, then checks the returned ``bool``:
+
+.. code-block:: cpp
+
+ auto driver_registry::add(std::string_view name, kstd::shared_ptr<filesystem> driver) -> bool
+ {
+ auto result = m_drivers.emplace(name, driver);
+ if (!result.second)
+ {
+ kstd::println(kstd::print_sink::stderr, "[OS:VFS] Tried to register duplicate filesystem driver '{}'!", name);
+ }
+ return result.second;
+ }
+
+A duplicate filesystem name is rejected correctly, relying on ``flat_map::emplace()``'s own semantics rather than being reimplemented by hand.
+
+Boot Ordering, Precisely
+------------------------
+
+Both device-driver registration paths run to completion *before* the corresponding device-attachment path runs.
+The kernel's ``main()`` function makes the ordering an explicit, readable sequence rather than an accident of link order:
+
+.. code-block:: cpp
+
+ kapi::devices::init(); // registries themselves come into existence
+ kernel::drivers::init(); // kernel_drivers section walked → drivers registered
+ kernel::devices::init(); // generic devices attached → matched against drivers already registered
+ kapi::devices::init_platform_drivers(); // platform_drivers section walked → drivers registered
+ kapi::devices::init_platform_devices(); // platform devices attached → matched against drivers already registered
+
+This ordering is load-bearing, not cosmetic.
+``bus::add_child()`` immediately offers a newly-attached device to ``driver_registry::device_attached()`` (:doc:`tb0004-device-tree-ownership-and-registries`), which tries every *currently registered* driver against it.
+A driver registered after its matching device was already attached simply never gets a chance to bind, with no error raised anywhere, since a device is only offered once, at attachment time.
+Placing both ``*_init()`` calls (drivers) strictly before their corresponding devices call is what makes "define a driver, do nothing else" actually work in practice.
+It is the same guarantee the driver guide's pseudo-bus example (:doc:`/guides/device-drivers`) relies on when it notes that ``null`` and ``zero`` are already registered by the time their devices are attached.
+
+Benefits
+--------
+
+**Adding a driver is a strictly local change.**
+ One ``descriptor`` struct and one ``kernel_driver<descriptor>``/``platform_driver<descriptor>``/``driver_module<descriptor>`` line.
+ Everything is entirely inside the new driver's own ``.cpp`` file.
+ No other file in the tree needs an edit.
+
+**No runtime cost paid before a driver is actually used.**
+ A descriptor is an inert, read-only object until ``make_instance()`` is called on it explicitly.
+ This call happens at a controlled point during the boot process.
+
+**No static-initialization-order question to reason about.**
+ Because nothing here depends on dynamic initialization at all, there is no ordering hazard between two drivers.
+ Similarly, there is no ordering hazard between a driver and the subsystems it depends on.
+ The "static initialization order fiasco" is avoided completely.
+
+Drawbacks and Limitations
+-------------------------
+
+**The mechanism depends on toolchain- and linker-specific behavior.**
+ ``__attribute__((section(...)))``, ``KEEP()``, and the ``__start_*``/``__stop_*`` symbol convention are GNU-linker (and GNU/Clang-compatible-attribute) features, not standard C++.
+ Porting the kernel to a fundamentally different toolchain would mean re-deriving this entire mechanism, not recompiling it as-is.
+
+**Registration order within one section is unspecified.**
+ Drivers are visited in whatever order the linker happened to place their contributing object files.
+ There is nothing a driver author can control or can rely on when it comes to the order of descriptor visitation.
+ Contrast FreeBSD's ``LINKER_SET`` family, which is the same underlying idea but built around named, individually-addressable set members [2]_.
+ Alternatively, Linux's numbered ``initcallN`` levels give a coarse, explicit priority a driver can opt into [3]_ .
+
+**A driver silently excluded from the build produces no diagnostic anywhere in this mechanism.**
+ If a ``.cpp`` file defining a ``kernel_driver<descriptor>`` is accidentally left out of the CMake target's source list, the kernel links, boots, and runs with that driver simply absent.
+ There is no way of distinguishing this situation from "the driver was not written".
+
+**The same idiom is implemented three separate times**
+ ``kernel_driver``, ``platform_driver``, and ``driver_module`` are three distinct templates with three distinct backing sections and three distinct walk functions.
+ They only differ in the ``driver_descriptor`` type and section name involved.
+ A generic ``self_registering<Category, DescriptorType>`` template parameterized on both is a plausible unification that has not been attempted.
+ As of today, a bug fixed in one walk function is not automatically fixed in the other two.
+
+**There is no compile-time or link-time list of what got registered**
+ The only way to see the full set of active drivers is to read the boot log's ``[OS:DRV]``/``[ARCH:DRV]``/``[OS:VFS]`` lines at runtime.
+ Alternatively, the built ELF image's ``kernel_drivers``/``platform_drivers``/``filesystem_drivers`` sections can be inspected directly with a tool like ``nm`` or ``readelf``.
+
+Parallels in Other Systems
+--------------------------
+
+**FreeBSD and NetBSD's ``LINKER_SET`` family**
+ A direct kernel-level ancestor of this idea: a set of macros that place a pointer into a named, linker-collected section, discovered at boot the same way, via ``__start_``/``__stop_``-style bracketing symbols the linker script provides [2]_.
+ Anyone who has read FreeBSD's ``newbus`` source will recognize this brief's mechanism immediately; TeachOS's version is a small, from-scratch C++ reimplementation of the same underlying trick, not a port of FreeBSD's macros.
+
+**Linux's ``initcall`` levels**
+ Linux solves the identical problem, code that must run once, early, without a central list of what to call, with C functions instead of C++ objects, placed into one of several *numbered* sections (``.initcall1.init`` through ``.initcall7.init``).
+ ``do_initcalls()`` walks in level order during boot [3]_.
+ The numbered levels are the one capability this brief's mechanism does not have: a Linux subsystem can guarantee it initializes before or after another subsystem by choosing its level.
+ As of today, in TeachOS, a driver has only "kernel" or "platform" to choose between, and no finer control within either.
+
+Driver Development Checklist
+----------------------------
+
+* To add a new driver: write a ``driver_descriptor`` (or ``kernel::vfs::driver_descriptor`` for a filesystem) in an anonymous namespace in your driver's own ``.cpp`` file, and register it with exactly one ``[[gnu::used]] constexpr auto registration = kernel_driver<descriptor>{};`` line (``platform_driver`` or ``driver_module`` as appropriate).
+ Nothing else in the tree needs to change.
+* Choose the section deliberately: ``kernel_driver`` for architecture-independent drivers, ``platform_driver`` for architecture-specific ones.
+ This choice determines which boot-time walk finds your driver, and therefore which devices will already exist by the time it is registered.
+* Never remove ``[[gnu::used]]`` from a registration object "because it isn't referenced anywhere".
+ That is precisely why it needs the attribute, not a sign that the attribute is unnecessary.
+* Do not assume any ordering relative to other drivers in the same section.
+ If two drivers must initialize in a specific relative order, that guarantee has to come from somewhere else (the device tree's own attach order, or an explicit dependency the drivers express to each other).
+ Do not rely on any order from this mechanism.
+* Confirm a new driver's ``.cpp`` file is actually part of the relevant CMake target's sources — this mechanism gives no diagnostic at all for a driver that was simply never compiled in.
+
+References
+-------------
+
+.. [1] ISO/IEC TS 19568:2015, *C++ Extensions for Library Fundamentals*, section on ``observer_ptr`` (``std::experimental::observer_ptr``). `Online <https://www.iso.org/standard/65112.html>`_.
+
+.. [2] FreeBSD Project, ``sys/sys/linker_set.h`` and the ``linker_set(9)`` manual page (``DATA_SET``, ``TEXT_SET``, and the ``SYSINIT``/module registration machinery built on the same section-collection mechanism). `Online <https://man.freebsd.org/cgi/man.cgi?query=linker_set&sektion=9>`_.
+
+.. [3] Torvalds, L. et al., Linux kernel source, ``include/linux/init.h`` (``module_init()``, ``subsys_initcall()`` and the numbered ``initcall`` levels) and ``init/main.c`` (``do_initcalls()``). See also Corbet, J., Rubini, A., and Kroah-Hartman, G., *Linux Device Drivers*, 3rd ed., O'Reilly, 2005, chapter 2, "Building and Running Modules."
+
+.. seealso::
+
+ ``kapi/kapi/devices/driver_registry.hpp`` and ``kernel/kernel/drivers/init.cpp`` / ``arch/x86_64/kapi/devices.cpp`` — the ``kernel_driver``/``platform_driver`` mechanism this brief quotes from directly.
+
+ ``kernel/kernel/vfs/driver_registry.hpp``/``.cpp`` — the independent, structurally parallel ``driver_module`` mechanism for filesystem drivers.
+
+ ``arch/x86_64/scripts/kernel.ld`` — the linker script sections (``kernel_drivers``, ``platform_drivers``, ``filesystem_drivers``) this brief quotes from directly.
+
+ ``kernel/kernel/drivers/pseudo/null.cpp`` and ``kernel/kernel/filesystems/ext2/module.cpp`` — the two complete, real registration examples this brief walks through.
+
+ :doc:`tb0004-device-tree-ownership-and-registries` — ``driver_registry`` itself, and the ``device_registry::add()`` duplicate-name issue this brief contrasts against ``vfs::driver_registry::add()``.
+
+ :doc:`../guides/device-drivers` — self-registration in practical use, and the boot-ordering guarantee this brief derives in full.
diff --git a/docs/guides/device-drivers.rst b/docs/guides/device-drivers.rst
index 61a91ddf..3d3da182 100644
--- a/docs/guides/device-drivers.rst
+++ b/docs/guides/device-drivers.rst
@@ -1,2 +1,250 @@
Implementing a Device Driver
-============================ \ No newline at end of file
+============================
+
+This guide walks through the core of TeachOS's device/driver model — the four types that make up the device tree, how a driver gets matched and bound to a device, and a complete worked example already living in the tree.
+It assumes the facet-dispatch mechanism (``facet_id``, ``device.facet<T>()``, ``query_facet()``) is already familiar; if it is not, read *Technical Brief 0003, "Facet-Based Capability Dispatch in the Device Model"* first (:doc:`/briefs/tb0003-facet-based-capability-dispatch`) — this guide builds directly on it and does not re-derive it.
+Two related topics — the declarative resource model (``kapi::devices::resource``, for MMIO/port/IRQ/DMA assignment, :doc:`/briefs/tb0005-device-resources`) and the linker-section-based driver self-registration mechanism (:doc:`/briefs/tb0006-compile-time-driver-self-registration`) — are used here only as much as the worked example needs.
+
+The Four Building Blocks
+--------------------------
+
+Every part of the device tree is built from exactly four types, all in ``kapi::devices`` (``kapi/kapi/devices/*.hpp``, included together as ``<kapi/devices.hpp>``):
+
+* **A device** (``device``) is an inert tree node. It carries an identity (a tree name, not a stable one — see below), a lifecycle state, a weak back-pointer to its parent, an optional bound driver, opaque driver-owned data, and a list of assigned resources. It does no work on its own; every behavior a concrete device type has beyond that comes from the facets it implements (:doc:`/briefs/tb0003-facet-based-capability-dispatch`).
+* **A bus** (``bus : device``) is a device that owns children. It holds them by ``shared_ptr`` (a bus's destructor tears down its whole subtree), and it optionally carries a ``bus_protocol *``. A bus with no protocol can still hold children (attached directly, one at a time, by whatever constructs it) but cannot autonomously enumerate them or match drivers against them.
+* **A driver** (``driver``) is stateless logic: ``probe()`` to claim and initialize a device, ``unbind()`` to undo that, and ``suspend()``/``resume()`` for power management. A driver instance is never itself a node in the tree — it can be bound to any number of devices, one at a time, over its lifetime.
+* **A bus protocol** (``bus_protocol``) is the bus-specific behavior a ``bus`` delegates to: ``enumerate()`` discovers a bus's children, and ``match()`` scores how well a given driver fits a given device. This is the one piece of the model every new kind of bus (ISA, a future PCI, TeachOS's own pseudo-device bus below) has to write for itself; everything else is reused as-is.
+
+.. code-block:: text
+
+ root (bus)
+ └── pseudo (bus, kernel::bus::pseudo, has a bus_protocol)
+ ├── "null" (device, publishes kernel::bus::pseudo_signature)
+ │ bound to: kernel::drivers::pseudo::null (a driver)
+ └── "zero" (device, publishes kernel::bus::pseudo_signature)
+ bound to: kernel::drivers::pseudo::zero (a driver)
+
+A device's tree name (the string passed to its constructor) is unique only within its parent bus, not globally — it is what shows up in logs and in ``kapi::devices::device_registry::find()``, not a stable identifier a filesystem or a persistent record should depend on.
+
+The Device Lifecycle
+-----------------------
+
+``kapi::devices::state`` (``device.hpp``) is a small, linear state machine, and every transition happens at one specific, findable call site:
+
+.. code-block:: cpp
+
+ enum struct state
+ {
+ uninitialized, // constructed, not yet attached to a bus
+ present, // attached to a bus, not yet matched to a driver
+ probing, // a driver's probe() is currently running
+ bound, // a driver successfully claimed this device
+ failed, // every candidate driver's probe() failed
+ removed, // detached from its bus; the object may still be kept alive elsewhere
+ };
+
+* ``uninitialized → present`` happens inside ``bus::add_child()``, immediately after the device is registered with ``device_registry`` and pushed into the parent's child list.
+* ``present → probing → (bound | failed)`` happens inside ``driver_registry``'s matching loop, described next.
+* ``(bound | failed | present) → removed`` happens inside ``bus::do_remove_child()``, which also calls the bound driver's ``unbind()`` (if any), withdraws every facet the device published (``facet_registry::withdraw_all_for()``), and unregisters the device from ``device_registry`` — all before the device object itself is necessarily destroyed. A device can stay alive past ``removed`` if something else still holds a ``shared_ptr`` to it (mirroring the reasoning in ``kapi::devices::facet_registry``'s own documentation about weak references outliving a device's tree membership).
+
+Matching and Binding
+------------------------
+
+Attaching a device to a bus does not, by itself, bind a driver to it. That is ``driver_registry``'s job (``kapi::devices::driver_registry``, a single global instance), and it runs the same algorithm from two different triggers:
+
+* ``driver_registry::add(driver)`` — a new driver was just registered; try it against every currently-unbound device in the whole tree.
+* ``driver_registry::device_attached(device)`` — a new device was just attached to a bus (called automatically from ``bus::add_child()``); try every currently-registered driver against it.
+
+Both funnel into ``try_bind()``:
+
+.. code-block:: cpp
+
+ auto driver_registry::try_bind(kstd::shared_ptr<device> const & device) -> void
+ {
+ if (!device || device->state() == state::bound) { return; } // sticky binding
+
+ auto parent = device->parent();
+ auto protocol = parent ? parent->facet<bus_protocol>() : nullptr;
+ if (!protocol) { return; } // no bus_protocol, e.g. a plain bus with no matching story
+
+ // ... collect {priority, driver} for every registered driver where protocol->match() succeeds ...
+ std::ranges::stable_sort(candidates, std::ranges::greater{}, &candidate::priority);
+
+ for (auto const & candidate : candidates)
+ {
+ device->bind_driver(candidate.driver_handle);
+ device->set_state(state::probing);
+
+ if (candidate.driver_handle->probe(*device))
+ {
+ device->set_state(state::bound);
+ return; // done — the highest-priority driver that actually probed successfully wins
+ }
+
+ device->bind_driver(kstd::weak_ptr<driver>{}); // that candidate failed; try the next
+ }
+
+ if (!candidates.empty()) { device->set_state(state::failed); }
+ }
+
+Three things worth being deliberate about, because each one is a real design decision, not an accident of implementation:
+
+* **Priority, not registration order, decides the winner.** Every driver whose ``match()`` returns a value (rather than an error) is a candidate; candidates are sorted by priority, highest first, and tried in that order. Registration order only breaks exact ties (``stable_sort``). A bus with only one plausible driver per device (ISA, the pseudo bus below) always returns a fixed priority from ``match()`` — see ISA's own ``match()`` in :doc:`/briefs/tb0003-facet-based-capability-dispatch` for why that is a legitimate degenerate case, not unfinished code.
+* **A failed ``probe()`` falls through, not fails outright.** If the highest-priority candidate's ``probe()`` returns an error, the device is unbound again and the *next* candidate is tried — a driver can lose at the probe step for a reason ``match()`` had no way to see (a resource already claimed by something else, say).
+* **Binding is sticky.** ``try_bind()`` returns immediately for a device already in ``state::bound``. A driver registered after the fact never steals an already-bound device, no matter what priority it would have scored — rebinding is not something this loop ever does on its own.
+
+``match()``'s error channel distinguishes "not a candidate" from "something actually went wrong": ``kapi::devices::driver_match_errc::no_match`` (and ``no_device_signature``) both map to ``kstd::errc::not_supported`` via their category's ``default_error_condition()``, and ``try_bind()`` only logs a warning for an error that maps to something *else* — the ordinary case of twenty unrelated drivers all declining a device produces no log spam, while a genuine probing failure inside ``match()`` itself still surfaces.
+
+A Worked Example: the Pseudo Device Bus
+-------------------------------------------
+
+TeachOS's simplest real bus, ``kernel::bus::pseudo`` (``kernel/kernel/bus/pseudo.{hpp,cpp}``), backs ``/dev/null`` and ``/dev/zero``. It has no real hardware behind it at all, which makes it a better first example to trace than ISA — there is nothing to get right beyond the device model itself.
+
+**The identification pair.** Exactly the ``*_signature``/``*_claim`` pattern from :doc:`/briefs/tb0003-facet-based-capability-dispatch`, this time matching by an arbitrary name rather than a fixed ISA identifier:
+
+.. code-block:: cpp
+
+ struct pseudo_signature
+ {
+ constexpr auto static id = kapi::capabilities::facet_id{"sig.dev.pseudo"};
+ virtual ~pseudo_signature() = default;
+ [[nodiscard]] virtual auto name() const noexcept -> std::string_view = 0;
+ };
+
+ struct pseudo_claim
+ {
+ constexpr auto static id = kapi::capabilities::facet_id{"clm.drv.pseudo"};
+ virtual ~pseudo_claim() = default;
+ [[nodiscard]] virtual auto supported_names() const noexcept -> std::span<std::string_view const> = 0;
+ };
+
+**The bus and its protocol.** ``pseudo_protocol::match()`` (an anonymous-namespace type local to ``pseudo.cpp``) checks whether the device's signature name appears in the driver's claimed names, and returns a fixed priority (``0u``) on a hit — there is never more than one candidate driver per pseudo device, so there is nothing to rank:
+
+.. code-block:: cpp
+
+ struct pseudo final : kapi::devices::bus
+ {
+ pseudo() : kapi::devices::bus{"pseudo", protocol_instance} {} // protocol_instance: a static pseudo_protocol
+ };
+
+**The device — a third way to implement a facet.** :doc:`/briefs/tb0003-facet-based-capability-dispatch` shows a facet implemented by multiple inheritance (``pit`` directly *is* an ``isa_signature``) and by composition through the facet registry (``ram_disk``'s driver-owned ``block_node``). ``kernel::devices::pseudo`` uses a third, equally valid shape: a facet implementation held as a plain **member**, with ``query_facet()`` handing out its address:
+
+.. code-block:: cpp
+
+ struct pseudo_signature final : kernel::bus::pseudo_signature
+ {
+ explicit constexpr pseudo_signature(kapi::devices::device const & device) : m_name{device.name()} {}
+ [[nodiscard]] auto name() const noexcept -> std::string_view override { return m_name; }
+ private:
+ std::string_view m_name;
+ };
+
+ struct pseudo final : kapi::devices::device
+ {
+ explicit pseudo(kstd::string name);
+ protected:
+ auto query_facet(kapi::capabilities::facet_id facet) -> void * override;
+ private:
+ pseudo_signature m_signature{*this};
+ };
+
+ auto pseudo::query_facet(kapi::capabilities::facet_id facet) -> void *
+ {
+ if (facet == kernel::bus::pseudo_signature::id) { return &m_signature; }
+ return device::query_facet(facet);
+ }
+
+This is worth choosing deliberately, not by default: multiple inheritance is right when the device type and the facet are the same concept end to end (``pit`` *is* its own ISA identity); a member is right when the facet's implementation needs its own small piece of state derived from the device (here, just the device's own name, captured once at construction) without the device type itself needing to satisfy the facet's interface through inheritance. Both forward to ``device::query_facet()`` for anything they don't recognize — the delegation rule from :doc:`/briefs/tb0003-facet-based-capability-dispatch` applies identically regardless of which shape you pick.
+
+**The driver.** ``null`` (``kernel/kernel/drivers/pseudo/null.{hpp,cpp}``) implements ``pseudo_claim`` and the two lifecycle methods every driver must provide:
+
+.. code-block:: cpp
+
+ auto null::probe(kapi::devices::device & device) -> kstd::result<void>
+ {
+ auto signature = device.facet<kernel::bus::pseudo_signature>();
+ if (!signature) { return kstd::failure(kapi::devices::driver_match_errc::no_device_signature); }
+ if (signature->name() != "null") { return kstd::failure(kapi::devices::driver_match_errc::invalid_device_signature); }
+
+ auto implementation = kstd::make_shared<null_node>(); // null_node : kapi::filesystem::character_special_file
+ auto published = kapi::devices::publish_facet<kapi::filesystem::character_special_file>(
+ device.shared_from_this(), "null", implementation.get());
+ if (!published) { return published; }
+
+ device.set_driver_data(std::move(implementation)); // keeps null_node alive for as long as the binding lasts
+ return kstd::success();
+ }
+
+ auto null::unbind(kapi::devices::device & device) -> void
+ {
+ device.set_driver_data(nullptr); // drops the driver's last reference to null_node
+ }
+
+Two things to notice, both general rules rather than quirks of this one driver:
+
+* **``probe()`` re-checks identity even though ``match()`` already checked it.** ``match()``'s job is to *rank* candidates before any one of them is committed to; ``probe()``'s job is to actually claim the device, and it is the one place a driver can fail for a reason specific to itself (here, a name mismatch would mean ``match()`` and ``probe()`` disagree — a bug worth catching defensively, distinguished from "no signature at all" by a separate ``driver_match_errc`` value). Do not assume ``probe()`` is only ever called on a device that already passed every check you might imagine — check what you depend on.
+* **``unbind()`` must undo exactly what ``probe()`` did, and nothing this driver does bypasses it.** ``null`` published a facet and stored driver data; ``unbind()`` clears the driver data (which drops the last strong reference to the published implementation) and nothing else — there is no separate facet withdrawal call here because ``bus::do_remove_child()`` already calls ``facet_registry::withdraw_all_for()`` unconditionally on removal. A driver that needs to unpublish facets *without* the device being removed from the tree (an explicit unbind requested by something other than removal) would need to call ``facet_registry::get().withdraw()`` itself — ``null`` never hits that path today, but a driver that can be unbound while its device stays attached should not assume ``unbind()`` is always followed by tree removal.
+
+**Registering the driver.** ``null`` never appears in a hand-maintained list anywhere. A small, file-local descriptor and a ``constexpr`` instance are enough:
+
+.. code-block:: cpp
+
+ namespace
+ {
+ struct descriptor final : kapi::devices::driver_descriptor
+ {
+ [[nodiscard]] auto name() const noexcept -> std::string_view override { return "dev_null"; }
+ [[nodiscard]] auto make_instance() const -> kstd::shared_ptr<kapi::devices::driver> override
+ {
+ return kstd::make_shared<null>();
+ }
+ };
+
+ [[gnu::used]]
+ constexpr auto registration = kapi::devices::kernel_driver<descriptor>{};
+ }
+
+``kernel_driver<Type>`` places a pointer to this descriptor into a dedicated linker section (``kernel_drivers``); at boot, ``kernel::drivers::init()`` walks that section, constructs one instance of every driver found in it via ``make_instance()``, and registers each with ``driver_registry::get().add()`` — the mechanism itself (why a linker section, how the section boundaries are exposed to C++, why this needs no runtime constructor to work, and how the identical idiom is reused for filesystem drivers) is covered in full in :doc:`/briefs/tb0006-compile-time-driver-self-registration` and is not repeated here. The practical rule for writing a new driver: add exactly this ``descriptor``/``kernel_driver<descriptor>`` pair (or ``platform_driver<descriptor>`` for a driver that belongs to platform rather than generic-kernel code) to your driver's ``.cpp`` file, and nothing elsewhere needs to know your driver exists.
+
+**Putting a device on the bus.** All of the above only takes effect once a device actually exists and is attached — which, for the pseudo bus, is three lines in ``kernel::devices::init()`` (``kernel/kernel/devices/init.cpp``):
+
+.. code-block:: cpp
+
+ auto init() -> void
+ {
+ auto root = kapi::devices::get_root_bus();
+
+ auto pseudo_bus = kstd::make_shared<kernel::bus::pseudo>();
+ root->add_child(pseudo_bus);
+
+ pseudo_bus->add_child(kstd::make_shared<devices::pseudo>("null"));
+ pseudo_bus->add_child(kstd::make_shared<devices::pseudo>("zero"));
+ }
+
+Each ``add_child()`` call runs the full sequence from *The Device Lifecycle* and *Matching and Binding* above: the device is registered, marked ``present``, and immediately offered to ``driver_registry::device_attached()``. Because ``kernel::drivers::init()`` (which registers ``null`` and ``zero`` via their ``kernel_driver<descriptor>`` self-registration) runs *before* ``kernel::devices::init()`` in ``kernel::main`` — a real ordering constraint, not an implementation accident, derived in full in :doc:`/briefs/tb0006-compile-time-driver-self-registration` — both drivers already exist in ``driver_registry`` by the time these two ``add_child()`` calls fire, so each pseudo device is matched and bound synchronously, inline, before ``kernel::devices::init()`` returns.
+
+Resources, Briefly
+----------------------
+
+A device can also carry a list of declarative ``kapi::devices::resource`` values (``mmio_range``, ``io_port``, ``interrupt_line``, ``dma_channel``) — assigned by whatever constructs the device, before it is attached, and requested inside ``probe()``:
+
+.. code-block:: cpp
+
+ auto port = device.request_resource<kapi::devices::resource_type::port>();
+ if (!port) { return kstd::failure(port.error()); }
+
+This keeps a driver from hard-coding an address or a port number that really belongs to whatever bus discovered the device (see the ISA PIT driver, ``arch/x86_64/arch/drivers/pit.cpp``, for a complete example). The resource type system itself — the tagged-union shape of ``resource``, how a bus assigns resources during enumeration, and its real drawbacks (no overlap detection, no automatic release on unbind) — is out of scope for this guide; see :doc:`/briefs/tb0005-device-resources` for the full treatment.
+
+Suspend and Resume, Briefly
+--------------------------------
+
+``driver::suspend()``/``resume()`` default to no-ops, so a driver that has nothing to do on a power transition needs no overrides at all. ``kapi::devices::suspend_tree()``/``resume_tree()`` (``kapi/kapi/devices/power.hpp``) walk a bus's subtree depth-first — children suspended before their parents, and a failed suspend rolls back everything already suspended in that same walk, in reverse order, before reporting the error. A driver that overrides ``suspend()`` must leave the device either fully suspended or exactly as it found it; there is no partially-suspended state this mechanism is prepared to resume from.
+
+Checklist
+------------
+
+* Every ``query_facet()`` override ends with a call to its immediate base's ``query_facet()`` for anything it doesn't recognize — see the delegation rule in :doc:`/briefs/tb0003-facet-based-capability-dispatch`.
+* ``probe()`` re-validates what ``match()`` already checked once, rather than trusting that a successful ``match()`` guarantees a successful ``probe()``.
+* ``unbind()`` undoes exactly what ``probe()`` did — released resources, withdrawn facets your driver published explicitly (tree removal withdraws the rest for you), cleared driver data.
+* A new facet type needs no changes anywhere in ``kapi::devices``; a new driver needs no changes anywhere except its own ``.cpp`` file, via ``kernel_driver<descriptor>``/``platform_driver<descriptor>``.
+* Adding new source files means updating ``CMakeLists.txt`` — easy to forget, and nothing here catches it for you at review time.