bej: add deferred binding substitution support

Add DSP0218 Table 42 deferred binding substitution to the BEJ encoder
and decoder so resource-specific values (URIs, instance ids, action
targets, containing-resource links) travel as placeholders and bind
from a caller-supplied map.

Bindings are keyed by the placeholder token -- the text after the '%'
marker, with no leading '%' -- and map to the substitution value:

'''
using BejDeferredBindingMap = std::unordered_map<std::string, std::string>;
'''

A single flat map holds id-bearing markers (%L<id>, %I<id>,
%T<id>.<action>) and id-less markers (%C, %S) alike, with no
per-resource struct and no empty unused fields. Keys are built with the
bejBinding helpers (resourceLink, instanceId, action, chassis, system)
so the caller and libbej share one token grammar.

Supported markers (a subset of Table 42): %L, %I, %T, %C, %S, and the
%% escape. The remaining markers (multi-symbol %PD/%PF/%PI,
%P<id>.PAGE<offset>, %M, %U, %B, and the %. terminator) are not handled
yet and pass through unchanged. The decode side models markers as a
data table (marker, parameter arity, invalid form), so adding one is a
table row rather than a new branch.

Encoder: encode() takes an optional BejDeferredBindingMap, builds a
value->placeholder reverse map once, rewrites matching bejString leaves
to placeholders for the encode, then restores the caller's tree (value
pointers and format flags) so encode() has no side effect on the input.

Decoder: decode() takes the same map and runs in one of two modes. An
empty map (the default) disables resolution: deferred binding strings
and bejResourceLink ids are left raw (e.g. %L<id>), preserving the
input. A non-empty map enables resolution: a recognized token is
substituted, an unrecognized id becomes its Table 42 invalid form
(%L -> /invalid.PDR<id>, %I -> invalid, %T -> /invalid.<id>.<n>), and an
id-less marker with no binding stays raw. Substitution is gated on the
per-string SFLV deferred-binding bit, so ordinary data is never touched.
Placeholder parsing uses std::from_chars so malformed input cannot throw
across the C decoder frames. Resource ids are 32-bit; a numeric
bejResourceLink id beyond that range is treated as unrecognized rather
than truncated.

The token-key map type and marker constants live in
bej_deferred_binding.hpp; the bejBinding key builders live in
bej_deferred_binding_format.hpp, keeping <format> out of the core type
header. Both encode and decode share these so the directions stay in
sync.

Change-Id: Id7aa215fc1dc701d60418d9ee92cd73f5629df87
Signed-off-by: Ryan CJ Huang <ryan_huang@jabil.com>
diff --git a/include/libbej/bej_decoder_core.h b/include/libbej/bej_decoder_core.h
index 3f19765..d94b4c5 100644
--- a/include/libbej/bej_decoder_core.h
+++ b/include/libbej/bej_decoder_core.h
@@ -108,9 +108,13 @@
 
     /**
      * @brief Calls when a String property is found.
+     *
+     * @note deferredBinding is true when the bejString SFLV had the
+     * deferred-binding format bit set; the value may then contain
+     * %-placeholders for the callback to substitute.
      */
     int (*callbackString)(const char* propertyName, const char* value,
-                          size_t length, void* dataPtr);
+                          size_t length, bool deferredBinding, void* dataPtr);
 
     /**
      * @brief Calls when a Real value property is found.
diff --git a/include/libbej/bej_decoder_json.hpp b/include/libbej/bej_decoder_json.hpp
index 0ad521a..85713fa 100644
--- a/include/libbej/bej_decoder_json.hpp
+++ b/include/libbej/bej_decoder_json.hpp
@@ -3,6 +3,8 @@
 #include "bej_common.h"
 #include "bej_decoder_core.h"
 
+#include "bej_deferred_binding.hpp"
+
 #include <span>
 #include <string>
 #include <vector>
@@ -21,10 +23,17 @@
      *
      * @param[in] dictionaries - dictionaries needed for decoding.
      * @param[in] encodedPldmBlock - encoded PLDM block.
+     * @param[in] bindings - resource-id to substitution map selecting one of
+     * two modes. Empty (the default) disables resolution: deferred binding
+     * placeholders and bejResourceLink ids are left raw (e.g. %L<id>),
+     * preserving the input. Non-empty enables resolution: a recognized id is
+     * substituted, and an unrecognized one becomes its DSP0218 Table 42 invalid
+     * form (%L -> /invalid.PDR<id>, %I -> invalid, %T -> /invalid.<id>.<n>).
      * @return 0 if successful.
      */
     int decode(const BejDictionaries& dictionaries,
-               const std::span<const uint8_t> encodedPldmBlock);
+               const std::span<const uint8_t> encodedPldmBlock,
+               const BejDeferredBindingMap& bindings = {});
 
     /**
      * @brief Get the JSON output related to the latest call to decode.
diff --git a/include/libbej/bej_deferred_binding.hpp b/include/libbej/bej_deferred_binding.hpp
new file mode 100644
index 0000000..72a326e
--- /dev/null
+++ b/include/libbej/bej_deferred_binding.hpp
@@ -0,0 +1,41 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <unordered_map>
+
+namespace libbej
+{
+
+// Deferred binding substitution markers (DSP0218 Table 42). The leading '%'
+// flags a substitution; the following character selects the marker.
+inline constexpr char bejDeferredBindingMarker = '%';
+inline constexpr char bejDeferredBindingResourceLink = 'L'; // %L<id>
+inline constexpr char bejDeferredBindingInstanceId = 'I';   // %I<id>
+inline constexpr char bejDeferredBindingActionUri = 'T';    // %T<id>.<action>
+inline constexpr char bejDeferredBindingActionSeparator =
+    '.';                                                    // %T<id>.<action>
+inline constexpr char bejDeferredBindingChassis = 'C';      // %C
+inline constexpr char bejDeferredBindingSystem = 'S';       // %S
+
+/**
+ * @brief Deferred binding substitutions, keyed by placeholder body.
+ *
+ * The key is the placeholder text following the '%' marker, with no leading
+ * '%' (the "token"); the value is its substitution. The decoder resolves a
+ * placeholder by looking up its token; the encoder rewrites a matching value
+ * back to '%' + token. One flat container holds id-bearing markers
+ * (%L<id>, %I<id>, %T<id>.<action>) and id-less markers (%C, %S) alike, with no
+ * per-resource struct and no empty unused fields.
+ *
+ *   token "L30"   <-> placeholder "%L30"   (resource URI)
+ *   token "I30"   <-> placeholder "%I30"   (instance id)
+ *   token "T30.1" <-> placeholder "%T30.1" (action target)
+ *   token "C"     <-> placeholder "%C"     (containing chassis link)
+ *
+ * Build keys with the bejBinding helpers in bej_deferred_binding_format.hpp so
+ * the token grammar has a single source of truth.
+ */
+using BejDeferredBindingMap = std::unordered_map<std::string, std::string>;
+
+} // namespace libbej
diff --git a/include/libbej/bej_deferred_binding_format.hpp b/include/libbej/bej_deferred_binding_format.hpp
new file mode 100644
index 0000000..6fb7eb4
--- /dev/null
+++ b/include/libbej/bej_deferred_binding_format.hpp
@@ -0,0 +1,79 @@
+#pragma once
+
+#include "bej_deferred_binding.hpp"
+
+#include <cstdint>
+#include <format>
+#include <string>
+
+namespace libbej
+{
+
+/**
+ * @brief Builders for BejDeferredBindingMap keys: the placeholder body, with no
+ * leading '%'. Both the caller populating the map and libbej formatting
+ * placeholders go through these, so the DSP0218 Table 42 token grammar lives in
+ * one place.
+ */
+namespace bejBinding
+{
+
+/**
+ * @brief Token for the %L<id> resource-URI marker.
+ *
+ * @param[in] resourceId - 32-bit resource id.
+ * @return the token, e.g. "L30".
+ */
+inline std::string resourceLink(uint32_t resourceId)
+{
+    return std::format("{}{}", bejDeferredBindingResourceLink, resourceId);
+}
+
+/**
+ * @brief Token for the %I<id> instance-id marker.
+ *
+ * @param[in] resourceId - resource id.
+ * @return the token, e.g. "I30".
+ */
+inline std::string instanceId(uint32_t resourceId)
+{
+    return std::format("{}{}", bejDeferredBindingInstanceId, resourceId);
+}
+
+/**
+ * @brief Token for the %T<id>.<action> action-target marker.
+ *
+ * @param[in] resourceId - resource id.
+ * @param[in] actionId - action id (widened so it renders as a number).
+ * @return the token, e.g. "T30.1".
+ */
+inline std::string action(uint32_t resourceId, uint8_t actionId)
+{
+    return std::format("{}{}{}{}", bejDeferredBindingActionUri, resourceId,
+                       bejDeferredBindingActionSeparator,
+                       static_cast<uint32_t>(actionId));
+}
+
+/**
+ * @brief Token for the id-less %C containing-chassis marker.
+ *
+ * @return the token "C".
+ */
+inline std::string chassis()
+{
+    return std::string(1, bejDeferredBindingChassis);
+}
+
+/**
+ * @brief Token for the id-less %S containing-system marker.
+ *
+ * @return the token "S".
+ */
+inline std::string system()
+{
+    return std::string(1, bejDeferredBindingSystem);
+}
+
+} // namespace bejBinding
+
+} // namespace libbej
diff --git a/include/libbej/bej_encoder_json.hpp b/include/libbej/bej_encoder_json.hpp
index 79c0e18..4dc5682 100644
--- a/include/libbej/bej_encoder_json.hpp
+++ b/include/libbej/bej_encoder_json.hpp
@@ -3,6 +3,8 @@
 #include "bej_common.h"
 #include "bej_encoder_core.h"
 
+#include "bej_deferred_binding.hpp"
+
 #include <vector>
 
 namespace libbej
@@ -69,11 +71,15 @@
      * @param[in] dictionaries - dictionaries needed for encoding.
      * @param[in] schemaClass - BEJ schema class.
      * @param[in] root - pointer to a RedfishPropertyParent struct.
+     * @param[in] bindings - resource-id to substitution map. bejString leaves
+     * whose value matches a substitution are encoded as the corresponding
+     * placeholder. Defaults to empty (no substitution).
      * @return 0 if successful.
      */
     int encode(const struct BejDictionaries* dictionaries,
                enum BejSchemaClass schemaClass,
-               struct RedfishPropertyParent* root);
+               struct RedfishPropertyParent* root,
+               const BejDeferredBindingMap& bindings = {});
 
     /**
      * @brief Get the JSON encoded payload.
diff --git a/include/libbej/meson.build b/include/libbej/meson.build
index 6dc2446..f6029d5 100644
--- a/include/libbej/meson.build
+++ b/include/libbej/meson.build
@@ -2,6 +2,8 @@
     'bej_common.h',
     'bej_decoder_core.h',
     'bej_decoder_json.hpp',
+    'bej_deferred_binding.hpp',
+    'bej_deferred_binding_format.hpp',
     'bej_dictionary.h',
     'bej_encoder_core.h',
     'bej_encoder_json.hpp',
diff --git a/src/bej_decoder_core.c b/src/bej_decoder_core.c
index faa2f67..11a938f 100644
--- a/src/bej_decoder_core.c
+++ b/src/bej_decoder_core.c
@@ -602,7 +602,6 @@
  */
 static int bejHandleBejString(struct BejHandleTypeFuncParam* params)
 {
-    // TODO: Handle deferred bindings.
     const char* propName = bejGetPropName(params);
 
     if (params->sflv.valueLength == 0)
@@ -615,7 +614,7 @@
         RETURN_IF_CALLBACK_IERROR(
             params->decodedCallback->callbackString, propName,
             (const char*)(params->sflv.value), params->sflv.valueLength,
-            params->callbacksDataPtr);
+            params->sflv.format.deferredBinding, params->callbacksDataPtr);
     }
     params->state.encodedStreamOffset = params->sflv.valueEndOffset;
     return bejProcessEnding(params, /*canBeEmpty=*/false);
diff --git a/src/bej_decoder_json.cpp b/src/bej_decoder_json.cpp
index 7f29134..4d71983 100644
--- a/src/bej_decoder_json.cpp
+++ b/src/bej_decoder_json.cpp
@@ -1,8 +1,19 @@
 #include "bej_decoder_json.hpp"
 
-#include <string.h>
+#include "bej_common.h"
+#include "bej_decoder_core.h"
 
+#include "bej_deferred_binding_format.hpp"
+
+#include <array>
+#include <charconv>
+#include <cstdio>
+#include <cstring>
 #include <format>
+#include <limits>
+#include <optional>
+#include <string>
+#include <string_view>
 
 #define MAX_BEJ_STRING_LEN 65536
 
@@ -16,6 +27,7 @@
 {
     bool* isPrevAnnotated;
     std::string* output;
+    const BejDeferredBindingMap* bindings;
 };
 
 /**
@@ -174,16 +186,258 @@
 }
 
 /**
+ * @brief Parse a base-10 integer at str[start] without throwing.
+ *
+ * @param[in] str - the string to parse from (start must be <= str.length()).
+ * @param[in] start - index to begin parsing.
+ * @param[out] out - parsed value, valid only on success.
+ * @param[out] end - index just past the last parsed digit.
+ * @return true if at least one digit was parsed and the value fit in T.
+ */
+template <typename T>
+static bool parseDecimalAt(const std::string& str, size_t start, T& out,
+                           size_t& end)
+{
+    auto result =
+        std::from_chars(str.data() + start, str.data() + str.length(), out);
+    end = static_cast<size_t>(result.ptr - str.data());
+    return result.ec == std::errc{};
+}
+
+// DSP0218 Table 42 markers supported by this decoder, modelled as data:
+//  - a marker fitting an existing arity is just a new table row.
+//  - a marker with a new operand shape (e.g. %P<id>.PAGE<offset>) also needs a
+//    new BejMacroArity value and a parse branch below.
+namespace
+{
+
+// How a marker's trailing parameters are parsed, which also fixes the token
+// boundary.
+enum class BejMacroArity : uint8_t
+{
+    none,                // %C, %S: the token is just the marker text
+    resourceId,          // %L<id>, %I<id>: marker + decimal id
+    resourceIdDotAction, // %T<id>.<action>: marker + decimal id + '.' + action
+};
+
+// Shape of the DSP0218 Table 42 "invalid" form, emitted when resolution is
+// enabled (a non-empty map) but the placeholder cannot be resolved.
+enum class BejInvalidKind : uint8_t
+{
+    none,        // %C, %S: spec defines no invalid form; leave the token raw
+    resourcePdr, // %L<id>     -> "/invalid.PDR<id>"
+    literal,     // %I<id>     -> "invalid"
+    action,      // %T<id>.<n> -> "/invalid.<id>.<n>"
+};
+
+// A marker is matched as text, not a single character, so the table can hold
+// multi-symbol markers such as "%PD":
+//  - findMacroSpec recognizes it; longest-match wins over a shorter prefix.
+//  - each new operand shape needs its own BejMacroArity and invalid-form.
+struct BejMacroSpec
+{
+    std::string_view marker;
+    BejMacroArity arity;
+    BejInvalidKind invalid;
+};
+
+// Wrap a single-character marker constant as a string_view, so the table reuses
+// the marker constants instead of duplicating the character literals.
+constexpr std::string_view asMarker(const char& marker)
+{
+    return std::string_view(&marker, 1);
+}
+
+constexpr auto bejMacroSpecs = std::to_array<BejMacroSpec>({
+    {asMarker(bejDeferredBindingResourceLink), BejMacroArity::resourceId,
+     BejInvalidKind::resourcePdr},
+    {asMarker(bejDeferredBindingInstanceId), BejMacroArity::resourceId,
+     BejInvalidKind::literal},
+    {asMarker(bejDeferredBindingActionUri), BejMacroArity::resourceIdDotAction,
+     BejInvalidKind::action},
+    {asMarker(bejDeferredBindingChassis), BejMacroArity::none,
+     BejInvalidKind::none},
+    {asMarker(bejDeferredBindingSystem), BejMacroArity::none,
+     BejInvalidKind::none},
+});
+
+constexpr std::string_view bejInvalidPdrPrefix = "/invalid.PDR";
+constexpr std::string_view bejInvalidLiteral = "invalid";
+constexpr std::string_view bejInvalidActionPrefix = "/invalid.";
+
+// Find the marker whose text matches str at markerStart. Longest match wins, so
+// a multi-symbol marker is preferred over a single-symbol prefix of it.
+const BejMacroSpec* findMacroSpec(std::string_view str, size_t markerStart)
+{
+    const BejMacroSpec* match = nullptr;
+    for (const BejMacroSpec& spec : bejMacroSpecs)
+    {
+        if (str.compare(markerStart, spec.marker.length(), spec.marker) != 0)
+        {
+            continue;
+        }
+        if (match == nullptr || spec.marker.length() > match->marker.length())
+        {
+            match = &spec;
+        }
+    }
+    return match;
+}
+
+// Parse the placeholder body that begins at the marker text (one past the '%').
+// On success, bodyEnd is the index just past the token, and resourceId /
+// actionId hold the parsed numbers per the marker's arity. Returns false for a
+// malformed body, which the caller leaves raw.
+bool parseMacroBody(const std::string& str, size_t markerStart,
+                    const BejMacroSpec& spec, size_t& bodyEnd,
+                    uint32_t& resourceId, uint32_t& actionId)
+{
+    // Trailing parameters follow the marker text; a multi-symbol marker just
+    // shifts where they begin.
+    size_t paramStart = markerStart + spec.marker.length();
+    if (spec.arity == BejMacroArity::none)
+    {
+        bodyEnd = paramStart;
+        return true;
+    }
+    // from_chars reports overflow and "no digits" via its return, unlike
+    // std::stoul whose exception would unwind across the C decoder frames.
+    size_t idEnd = 0;
+    if (!parseDecimalAt(str, paramStart, resourceId, idEnd))
+    {
+        return false;
+    }
+    if (spec.arity == BejMacroArity::resourceId)
+    {
+        bodyEnd = idEnd;
+        return true;
+    }
+    // resourceIdDotAction requires a ".<action>" suffix.
+    if (idEnd >= str.length() ||
+        str[idEnd] != bejDeferredBindingActionSeparator)
+    {
+        return false;
+    }
+    size_t actEnd = 0;
+    if (!parseDecimalAt(str, idEnd + 1, actionId, actEnd))
+    {
+        return false;
+    }
+    bodyEnd = actEnd;
+    return true;
+}
+
+// Build the DSP0218 Table 42 invalid form for an unresolved placeholder.
+// Returns false when the marker has no invalid form (%C, %S), leaving it raw.
+bool buildInvalidForm(const BejMacroSpec& spec, uint32_t resourceId,
+                      uint32_t actionId, std::string& out)
+{
+    switch (spec.invalid)
+    {
+        case BejInvalidKind::resourcePdr:
+            out = std::format("{}{}", bejInvalidPdrPrefix, resourceId);
+            return true;
+        case BejInvalidKind::literal:
+            out = std::string(bejInvalidLiteral);
+            return true;
+        case BejInvalidKind::action:
+            out = std::format("{}{}{}{}", bejInvalidActionPrefix, resourceId,
+                              bejDeferredBindingActionSeparator, actionId);
+            return true;
+        case BejInvalidKind::none:
+            return false;
+    }
+    return false;
+}
+
+} // namespace
+
+/**
+ * @brief Substitute deferred binding placeholders in a string and append the
+ * result to the output.
+ */
+static void addDeferredBindingString(struct BejJsonParam* params,
+                                     const char* value, size_t length)
+{
+    if (length == 0)
+    {
+        return;
+    }
+    if (!params->bindings || params->bindings->empty())
+    {
+        // Resolution disabled: emit the raw string unchanged.
+        params->output->append(value, length - 1);
+        return;
+    }
+
+    std::string str(value, length - 1);
+    size_t pos = 0;
+    while ((pos = str.find(bejDeferredBindingMarker, pos)) != std::string::npos)
+    {
+        if (pos + 1 >= str.length())
+        {
+            // A lone '%' at the end of the string: no marker can follow.
+            break;
+        }
+        if (str[pos + 1] == bejDeferredBindingMarker)
+        {
+            // Escaped marker. Replace "%%" with "%".
+            str.replace(pos, 2, 1, bejDeferredBindingMarker);
+            pos++;
+            continue;
+        }
+
+        const BejMacroSpec* spec = findMacroSpec(str, pos + 1);
+        if (spec == nullptr)
+        {
+            // Unsupported marker: DSP0218 Table 42 passes it through unchanged.
+            pos++;
+            continue;
+        }
+
+        size_t bodyEnd = 0;
+        uint32_t resourceId = 0;
+        uint32_t actionId = 0;
+        if (!parseMacroBody(str, pos + 1, *spec, bodyEnd, resourceId, actionId))
+        {
+            // Malformed body: leave the placeholder raw.
+            pos++;
+            continue;
+        }
+
+        // The lookup token is the placeholder body (e.g. "L30", "T30.1", "C").
+        std::string token = str.substr(pos + 1, bodyEnd - (pos + 1));
+        std::string replacement;
+        auto it = params->bindings->find(token);
+        if (it != params->bindings->end() && !it->second.empty())
+        {
+            replacement = it->second;
+        }
+        else if (!buildInvalidForm(*spec, resourceId, actionId, replacement))
+        {
+            // No binding and no invalid form (%C, %S): leave the token raw.
+            pos = bodyEnd;
+            continue;
+        }
+        str.replace(pos, bodyEnd - pos, replacement);
+        pos += replacement.length();
+    }
+    params->output->append(str);
+}
+
+/**
  * @brief Callback for bejString type.
  *
  * @param[in] propertyName - a NULL terminated string.
  * @param[in] value - a NULL terminated string.
  * @param[in] length - length of the string.
+ * @param[in] deferredBinding - indicates if string contains deferred binding
+ * placeholders.
  * @param[in] dataPtr - pointing to a valid BejJsonParam struct.
  * @return 0 if successful.
  */
 static int callbackString(const char* propertyName, const char* value,
-                          size_t length, void* dataPtr)
+                          size_t length, bool deferredBinding, void* dataPtr)
 {
     if ((length > MAX_BEJ_STRING_LEN) ||
         (strnlen(value, length) != (length - 1)))
@@ -199,7 +453,14 @@
     params->output->push_back('\"');
     if (length > 0)
     {
-        params->output->append(value, length - 1);
+        if (deferredBinding)
+        {
+            addDeferredBindingString(params, value, length);
+        }
+        else
+        {
+            params->output->append(value, length - 1);
+        }
     }
     params->output->push_back('\"');
     *params->isPrevAnnotated = false;
@@ -300,8 +561,39 @@
     struct BejJsonParam* params =
         reinterpret_cast<struct BejJsonParam*>(dataPtr);
     addPropertyNameToOutput(params, propertyName);
-    params->output->append(std::format("\"%L{}\"", linkId));
     *params->isPrevAnnotated = false;
+
+    // bejResourceLink carries a numeric resource id, not a placeholder string,
+    // so it resolves here rather than inside addDeferredBindingString.
+    const bool resolutionEnabled =
+        params->bindings && !params->bindings->empty();
+    if (!resolutionEnabled)
+    {
+        // Resolution disabled (no bindings supplied): preserve the raw %L<id>
+        // placeholder, matching decode() called without a map.
+        params->output->append(
+            std::format("\"{}{}{}\"", bejDeferredBindingMarker,
+                        bejDeferredBindingResourceLink, linkId));
+        return 0;
+    }
+
+    // Resource ids are 32-bit; a link id beyond that range cannot name a
+    // resource, so it never matches and falls through to the invalid form
+    // rather than being truncated into a 32-bit token.
+    if (linkId <= std::numeric_limits<uint32_t>::max())
+    {
+        auto it = params->bindings->find(
+            bejBinding::resourceLink(static_cast<uint32_t>(linkId)));
+        if (it != params->bindings->end() && !it->second.empty())
+        {
+            params->output->append(std::format("\"{}\"", it->second));
+            return 0;
+        }
+    }
+
+    // Enabled but unrecognized (or out of 32-bit range): %L invalid form.
+    params->output->append(
+        std::format("\"{}{}\"", bejInvalidPdrPrefix, linkId));
     return 0;
 }
 
@@ -368,7 +660,8 @@
 }
 
 int BejDecoderJson::decode(const BejDictionaries& dictionaries,
-                           const std::span<const uint8_t> encodedPldmBlock)
+                           const std::span<const uint8_t> encodedPldmBlock,
+                           const BejDeferredBindingMap& bindings)
 {
     // Clear the previous output if any.
     output.clear();
@@ -409,6 +702,7 @@
     struct BejJsonParam callbackData = {
         .isPrevAnnotated = &isPrevAnnotated,
         .output = &output,
+        .bindings = &bindings,
     };
 
     return bejDecodePldmBlockWithPolicy(
diff --git a/src/bej_encoder_json.cpp b/src/bej_encoder_json.cpp
index 8117f76..1c19165 100644
--- a/src/bej_encoder_json.cpp
+++ b/src/bej_encoder_json.cpp
@@ -1,5 +1,13 @@
 #include "bej_encoder_json.hpp"
 
+#include "bej_deferred_binding_format.hpp"
+
+#include <algorithm>
+#include <deque>
+#include <string>
+#include <string_view>
+#include <vector>
+
 namespace libbej
 {
 
@@ -55,10 +63,127 @@
     return currentEncodedPayload;
 }
 
+// Records a leaf whose value was temporarily rewritten to a deferred binding
+// placeholder, so encode() can restore the caller's tree after encoding.
+struct DeferredBindingRestore
+{
+    struct RedfishPropertyLeafString* leaf;
+    const char* originalValue;
+    bool originalDeferredBinding;
+};
+
+// One (value, placeholder) entry of the reverse lookup: the property value the
+// caller wrote and the placeholder it is encoded as.
+struct ReverseDeferredBinding
+{
+    std::string value;
+    std::string placeholder;
+};
+
+// A flat table, not a hash map: the binding count is small (a handful per
+// resource), so a linear scan is faster and lighter than hashing, and it is
+// built once per encode.
+using ReverseDeferredBindingTable = std::vector<ReverseDeferredBinding>;
+
+static ReverseDeferredBindingTable buildReverseDeferredBindingTable(
+    const BejDeferredBindingMap& bindings)
+{
+    ReverseDeferredBindingTable reverse;
+    reverse.reserve(bindings.size());
+    for (const auto& [token, value] : bindings)
+    {
+        // Empty values never produce a placeholder. When distinct placeholders
+        // share a value the first match found on lookup wins, so callers should
+        // keep values unique.
+        if (!value.empty())
+        {
+            reverse.push_back(
+                {value, std::format("{}{}", bejDeferredBindingMarker, token)});
+        }
+    }
+    return reverse;
+}
+
+static void updateTreeForDeferredBindings(
+    struct RedfishPropertyNode* node,
+    const ReverseDeferredBindingTable& reverse,
+    std::deque<std::string>& storage,
+    std::vector<DeferredBindingRestore>& restores)
+{
+    if (!node || reverse.empty())
+    {
+        return;
+    }
+
+    if (bejTreeIsParentType(node))
+    {
+        struct RedfishPropertyParent* parent =
+            reinterpret_cast<struct RedfishPropertyParent*>(node);
+        struct RedfishPropertyNode* child =
+            reinterpret_cast<struct RedfishPropertyNode*>(parent->firstChild);
+        while (child != nullptr)
+        {
+            updateTreeForDeferredBindings(child, reverse, storage, restores);
+            child = reinterpret_cast<struct RedfishPropertyNode*>(
+                bejParentGoToNextChild(parent, child));
+        }
+        return;
+    }
+
+    // Only bejString leaves carry substitutable values on the encode side; the
+    // encoder core has no bejResourceLink leaf type to emit.
+    if (node->format.principalDataType != bejString)
+    {
+        return;
+    }
+
+    struct RedfishPropertyLeafString* leafStr =
+        reinterpret_cast<struct RedfishPropertyLeafString*>(node);
+    if (!leafStr->value)
+    {
+        return;
+    }
+
+    std::string_view value{leafStr->value};
+    auto match = std::find_if(reverse.begin(), reverse.end(),
+                              [value](const ReverseDeferredBinding& entry) {
+                                  return std::string_view{entry.value} == value;
+                              });
+    if (match == reverse.end())
+    {
+        return;
+    }
+
+    // Own the placeholder for the encode lifetime via `storage`; a deque keeps
+    // element addresses stable, so `value` may point at its c_str(). Record the
+    // original pointer and flag so encode() can restore the caller's tree.
+    restores.push_back(
+        {leafStr, leafStr->value, node->format.deferredBinding != 0});
+    storage.push_back(match->placeholder);
+    leafStr->value = storage.back().c_str();
+
+    bejTreeUpdateNodeFlags(node, /*deferredBinding=*/true,
+                           node->format.readOnlyPropertyAndTopLevelAnnotation,
+                           node->format.nullableProperty);
+}
+
 int BejEncoderJson::encode(const struct BejDictionaries* dictionaries,
                            enum BejSchemaClass schemaClass,
-                           struct RedfishPropertyParent* root)
+                           struct RedfishPropertyParent* root,
+                           const BejDeferredBindingMap& bindings)
 {
+    // Temporarily rewrite eligible string values to deferred binding
+    // placeholders. `storage` owns the placeholder strings for the encode
+    // duration; `restores` lets us put the caller's tree back afterwards so
+    // encode() leaves no side effect on the input.
+    ReverseDeferredBindingTable reverseBindings =
+        buildReverseDeferredBindingTable(bindings);
+    std::deque<std::string> deferredBindingStorage;
+    std::vector<DeferredBindingRestore> deferredBindingRestores;
+    updateTreeForDeferredBindings(
+        reinterpret_cast<struct RedfishPropertyNode*>(root), reverseBindings,
+        deferredBindingStorage, deferredBindingRestores);
+
     struct BejEncoderOutputHandler output = {
         .handlerContext = &encodedPayload,
         .recvOutput = &getBejEncodedBuffer,
@@ -73,8 +198,24 @@
         .deleteStack = nullptr,
     };
 
-    return bejEncode(dictionaries, BEJ_DICTIONARY_START_AT_HEAD, schemaClass,
-                     root, &output, &stackCallbacks);
+    int rc = bejEncode(dictionaries, BEJ_DICTIONARY_START_AT_HEAD, schemaClass,
+                       root, &output, &stackCallbacks);
+
+    // Restore the caller's tree (original value pointers and deferred binding
+    // flags); placeholders in `deferredBindingStorage` are unreferenced once
+    // this returns.
+    for (const DeferredBindingRestore& entry : deferredBindingRestores)
+    {
+        struct RedfishPropertyNode* node =
+            reinterpret_cast<struct RedfishPropertyNode*>(entry.leaf);
+        bejTreeUpdateNodeFlags(
+            node, entry.originalDeferredBinding,
+            node->format.readOnlyPropertyAndTopLevelAnnotation,
+            node->format.nullableProperty);
+        entry.leaf->value = entry.originalValue;
+    }
+
+    return rc;
 }
 
 } // namespace libbej
diff --git a/test/bej_decoder_test.cpp b/test/bej_decoder_test.cpp
index 5b9a013..8b26cf6 100644
--- a/test/bej_decoder_test.cpp
+++ b/test/bej_decoder_test.cpp
@@ -1,5 +1,6 @@
 #include "bej_common_test.hpp"
 #include "bej_decoder_json.hpp"
+#include "bej_deferred_binding_format.hpp"
 #include "bej_encoder_json.hpp"
 
 #include <memory>
@@ -474,6 +475,89 @@
     EXPECT_EQ(jsonDecoded["Id"].get<std::string>(), "%L42");
 }
 
+// Same ResourceLink (PDR id 42) as DecodeResourceLink, exercising the two
+// resolution modes a bindings map selects.
+static std::vector<uint8_t> resourceLink42Stream()
+{
+    return {
+        // PLDM header (7 bytes)
+        0x00,
+        0xF0,
+        0xF0,
+        0xF1, // bejVersion
+        0x00,
+        0x00, // reserved
+        0x00, // schemaClass (major)
+        // Root Set (DummySimple, seq=0)
+        0x01,
+        0x00, // S: seq=0
+        0x00, // F: bejSet
+        0x01,
+        0x09, // L: 9 bytes
+        0x01,
+        0x01, // element count: 1 child
+        // Child ResourceLink (Id, seq=1)
+        0x01,
+        0x02, // S: seq=1
+        0xE0, // F: bejResourceLink
+        0x01,
+        0x02, // L: 2 bytes
+        0x01,
+        0x2A, // V: PDR ID = 42
+    };
+}
+
+TEST(BejDecoderResourceLinkTest, DecodeResourceLinkResolvesUri)
+{
+    auto inputsOrErr = loadInputs(dummySimpleTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+    std::vector<uint8_t> encodedStream = resourceLink42Stream();
+
+    // Map contains resource 42: the link resolves to its URI.
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::resourceLink(42)] = "/redfish/v1/Systems/1";
+
+    BejDecoderJson decoder;
+    EXPECT_THAT(
+        decoder.decode(dictionaries, std::span(encodedStream), bindings), 0);
+    nlohmann::json jsonDecoded = nlohmann::json::parse(decoder.getOutput());
+    EXPECT_EQ(jsonDecoded["Id"].get<std::string>(), "/redfish/v1/Systems/1");
+}
+
+TEST(BejDecoderResourceLinkTest, DecodeResourceLinkUnrecognizedIsInvalidForm)
+{
+    auto inputsOrErr = loadInputs(dummySimpleTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+    std::vector<uint8_t> encodedStream = resourceLink42Stream();
+
+    // Non-empty map without resource 42: resolution is enabled, so the link
+    // takes the DSP0218 Table 42 invalid form rather than the raw "%L42".
+    BejDeferredBindingMap unrelated;
+    unrelated[bejBinding::resourceLink(99)] = "/redfish/v1/unrelated";
+
+    BejDecoderJson decoder;
+    EXPECT_THAT(
+        decoder.decode(dictionaries, std::span(encodedStream), unrelated), 0);
+    nlohmann::json jsonDecoded = nlohmann::json::parse(decoder.getOutput());
+    EXPECT_EQ(jsonDecoded["Id"].get<std::string>(), "/invalid.PDR42");
+}
+
 TEST(BejDecoderResourceLinkTest, DecodeResourceLinkNull)
 {
     // Test that ResourceLink with zero length is decoded as null.
@@ -691,4 +775,104 @@
               bejErrorInvalidSize);
 }
 
+// Fixtures captured from a real Intel E810 NIC. The BEJ payload is produced by
+// the device's RDE stack (not by this library's encoder) and its string
+// elements carry the deferred-binding bit, so it exercises the decode path
+// against an externally generated binary. See Gerrit 91082.
+const BejTestInputFiles networkAdapterDeferredBindingFiles = {
+    .jsonFile = "../test/json/network_adapter.json",
+    .schemaDictionaryFile = "../test/dictionaries/network_adapter_dict.bin",
+    .annotationDictionaryFile = "../test/dictionaries/annotation_dict.bin",
+    .errorDictionaryFile = "",
+    .encodedStreamFile = "../test/encoded/network_adapter_enc.bin",
+};
+
+constexpr const char* networkAdapterPdrFile =
+    "../test/pdr/network_adapter_pdr.json";
+constexpr const char* networkAdapterResolvedJsonFile =
+    "../test/json/network_adapter_resolved.json";
+
+/**
+ * @brief Load a deferred-binding map from a pdr.json mapping table.
+ *
+ * @param[in] pdrFile - path to a JSON object whose keys are placeholders with
+ *            the leading '%' marker (e.g. "%L1") and whose values are the
+ *            substitutions.
+ * @return map keyed by token body (no '%'), matching the decoder's keys, or
+ *         nullopt on a missing file or a malformed key.
+ */
+std::optional<BejDeferredBindingMap> loadBindingMap(const char* pdrFile)
+{
+    std::ifstream pdrInput(pdrFile);
+    if (!pdrInput.is_open())
+    {
+        return std::nullopt;
+    }
+    nlohmann::json pdr;
+    pdrInput >> pdr;
+
+    BejDeferredBindingMap bindings;
+    for (const auto& [placeholder, uri] : pdr.items())
+    {
+        // The decoder keys on the token body; pdr.json keeps the '%' marker
+        // only for readability, so strip it here.
+        if (placeholder.empty() ||
+            placeholder.front() != bejDeferredBindingMarker)
+        {
+            return std::nullopt;
+        }
+        bindings.emplace(placeholder.substr(1), uri.get<std::string>());
+    }
+    return bindings;
+}
+
+// Without a binding map the decoder must leave deferred-binding placeholders
+// untouched (DSP0218 8.3 passthrough), so a device payload stays decodable
+// before its resource links are known.
+TEST(BejDeferredBindingDecodeTest, PreservesPlaceholdersWithoutBindings)
+{
+    auto inputsOrErr = loadInputs(networkAdapterDeferredBindingFiles);
+    ASSERT_TRUE(inputsOrErr);
+    BejDictionaries dictionaries = makeDictionaries(*inputsOrErr);
+
+    BejDecoderJson decoder;
+    ASSERT_EQ(decoder.decode(dictionaries, inputsOrErr->encodedStream), 0);
+    nlohmann::json decoded = nlohmann::json::parse(decoder.getOutput());
+
+    // network_adapter.json still holds the "%L.."/"%I.." placeholders.
+    EXPECT_EQ(decoded.dump(), inputsOrErr->expectedJson.dump());
+}
+
+// With the PDR-derived map every placeholder must be substituted with its
+// resource URI. The resolved golden is produced independently of libbej, so
+// matching it proves the decoder applied the mapping table rather than echoing
+// its own substitution back.
+TEST(BejDeferredBindingDecodeTest, ResolvesPlaceholdersWithBindings)
+{
+    auto inputsOrErr = loadInputs(networkAdapterDeferredBindingFiles);
+    ASSERT_TRUE(inputsOrErr);
+    BejDictionaries dictionaries = makeDictionaries(*inputsOrErr);
+
+    auto bindings = loadBindingMap(networkAdapterPdrFile);
+    ASSERT_TRUE(bindings);
+
+    std::ifstream resolvedInput(networkAdapterResolvedJsonFile);
+    ASSERT_TRUE(resolvedInput.is_open());
+    nlohmann::json expectedResolved;
+    resolvedInput >> expectedResolved;
+
+    BejDecoderJson decoder;
+    ASSERT_EQ(
+        decoder.decode(dictionaries, inputsOrErr->encodedStream, *bindings), 0);
+    nlohmann::json decoded = nlohmann::json::parse(decoder.getOutput());
+    EXPECT_EQ(decoded.dump(), expectedResolved.dump());
+
+    // Tie the result to the mapping table, not to a coincidental literal: the
+    // self link and id must equal the map entries for resource id 1.
+    EXPECT_EQ(decoded.at("@odata.id").get<std::string>(),
+              bindings->at(bejBinding::resourceLink(1)));
+    EXPECT_EQ(decoded.at("Id").get<std::string>(),
+              bindings->at(bejBinding::instanceId(1)));
+}
+
 } // namespace libbej
diff --git a/test/bej_encoder_test.cpp b/test/bej_encoder_test.cpp
index f630a51..c04fa02 100644
--- a/test/bej_encoder_test.cpp
+++ b/test/bej_encoder_test.cpp
@@ -4,6 +4,7 @@
 
 #include "bej_common_test.hpp"
 #include "bej_decoder_json.hpp"
+#include "bej_deferred_binding_format.hpp"
 #include "bej_encoder_json.hpp"
 
 #include <vector>
@@ -443,6 +444,258 @@
     EXPECT_TRUE(jsonDecoded.dump() == inputsOrErr->expectedJson.dump());
 }
 
+TEST(BejEncoderDeferredBindingTest, DeferredBindingEncodeDecode)
+{
+    auto inputsOrErr = loadInputs(driveOemTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+
+    // We will use DriveOEM which has `@odata.id`: `/redfish/v1/drives/1`.
+    // Let's create a reverse mapping config where resourceId = 30 points to
+    // this URI.
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::resourceLink(30)] = "/redfish/v1/drives/1";
+    bindings[bejBinding::instanceId(30)] = "Drive1";
+    // Target action mapping simulation
+    bindings[bejBinding::action(30, 1)] =
+        "/redfish/v1/drives/1/Actions/Drive.Reset";
+
+    libbej::BejEncoderJson encoder;
+    struct RedfishPropertyParent* root = createDriveOem();
+
+    // Encode with binding map
+    encoder.encode(&dictionaries, bejMajorSchemaClass, root, bindings);
+
+    std::vector<uint8_t> outputBuffer = encoder.getOutput();
+
+    // Decode with binding map
+    libbej::BejDecoderJson decoder;
+    EXPECT_THAT(decoder.decode(dictionaries, std::span(outputBuffer), bindings),
+                0);
+    std::string decoded = decoder.getOutput();
+    nlohmann::json jsonDecoded = nlohmann::json::parse(decoded);
+
+    // After decode, the json should perfectly match the input strings
+    // because `%L30`, `%I30` should be resolved back to their original strings.
+    std::string odataId = jsonDecoded["@odata.id"].get<std::string>();
+    EXPECT_EQ(odataId, "/redfish/v1/drives/1");
+
+    std::string id = jsonDecoded["Id"].get<std::string>();
+    EXPECT_EQ(id, "Drive1");
+
+    // Action URI
+    std::string actionTarget =
+        jsonDecoded["Actions"]["#Drive.Reset"]["target"].get<std::string>();
+    EXPECT_EQ(actionTarget, "/redfish/v1/drives/1/Actions/Drive.Reset");
+}
+
+TEST(BejEncoderDeferredBindingTest, EncodeDoesNotMutateInputTree)
+{
+    auto inputsOrErr = loadInputs(driveOemTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::resourceLink(30)] = "/redfish/v1/drives/1";
+    bindings[bejBinding::instanceId(30)] = "Drive1";
+    bindings[bejBinding::action(30, 1)] =
+        "/redfish/v1/drives/1/Actions/Drive.Reset";
+
+    // encode() temporarily rewrites matching leaf values to placeholders, then
+    // must restore them (value pointers and deferred-binding flags) before
+    // returning.
+    struct RedfishPropertyParent* root = createDriveOem();
+    libbej::BejEncoderJson boundEncoder;
+    boundEncoder.encode(&dictionaries, bejMajorSchemaClass, root, bindings);
+
+    // Re-encoding the same tree WITHOUT bindings must reproduce a clean encode
+    // of an untouched tree; any residual placeholder or flag would differ.
+    libbej::BejEncoderJson reuseEncoder;
+    reuseEncoder.encode(&dictionaries, bejMajorSchemaClass, root);
+    std::vector<uint8_t> afterRestore = reuseEncoder.getOutput();
+
+    libbej::BejEncoderJson referenceEncoder;
+    referenceEncoder.encode(&dictionaries, bejMajorSchemaClass,
+                            createDriveOem());
+    std::vector<uint8_t> reference = referenceEncoder.getOutput();
+
+    EXPECT_EQ(afterRestore, reference);
+}
+
+TEST(BejEncoderDeferredBindingTest, UnresolvedPlaceholdersBecomeInvalidForm)
+{
+    auto inputsOrErr = loadInputs(driveOemTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::resourceLink(30)] = "/redfish/v1/drives/1";
+    bindings[bejBinding::instanceId(30)] = "Drive1";
+    bindings[bejBinding::action(30, 1)] =
+        "/redfish/v1/drives/1/Actions/Drive.Reset";
+
+    libbej::BejEncoderJson encoder;
+    encoder.encode(&dictionaries, bejMajorSchemaClass, createDriveOem(),
+                   bindings);
+    std::vector<uint8_t> outputBuffer = encoder.getOutput();
+
+    // Decode with a non-empty map that does NOT contain resource 30: resolution
+    // is enabled, so each unresolved placeholder must become its DSP0218
+    // Table 42 invalid form rather than being left raw.
+    BejDeferredBindingMap unrelated;
+    unrelated[bejBinding::resourceLink(99)] = "/redfish/v1/unrelated";
+
+    libbej::BejDecoderJson decoder;
+    EXPECT_THAT(
+        decoder.decode(dictionaries, std::span(outputBuffer), unrelated), 0);
+    nlohmann::json jsonDecoded = nlohmann::json::parse(decoder.getOutput());
+
+    EXPECT_EQ(jsonDecoded["@odata.id"].get<std::string>(), "/invalid.PDR30");
+    EXPECT_EQ(jsonDecoded["Id"].get<std::string>(), "invalid");
+    EXPECT_EQ(
+        jsonDecoded["Actions"]["#Drive.Reset"]["target"].get<std::string>(),
+        "/invalid.30.1");
+}
+
+TEST(BejEncoderDeferredBindingTest, DisabledModeLeavesPlaceholdersRaw)
+{
+    auto inputsOrErr = loadInputs(driveOemTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::resourceLink(30)] = "/redfish/v1/drives/1";
+    bindings[bejBinding::instanceId(30)] = "Drive1";
+    bindings[bejBinding::action(30, 1)] =
+        "/redfish/v1/drives/1/Actions/Drive.Reset";
+
+    libbej::BejEncoderJson encoder;
+    encoder.encode(&dictionaries, bejMajorSchemaClass, createDriveOem(),
+                   bindings);
+    std::vector<uint8_t> outputBuffer = encoder.getOutput();
+
+    // Decode without a map (the default): resolution is disabled, so every
+    // placeholder is preserved verbatim instead of being substituted.
+    libbej::BejDecoderJson decoder;
+    EXPECT_THAT(decoder.decode(dictionaries, std::span(outputBuffer)), 0);
+    nlohmann::json jsonDecoded = nlohmann::json::parse(decoder.getOutput());
+
+    EXPECT_EQ(jsonDecoded["@odata.id"].get<std::string>(), "%L30");
+    EXPECT_EQ(jsonDecoded["Id"].get<std::string>(), "%I30");
+    EXPECT_EQ(
+        jsonDecoded["Actions"]["#Drive.Reset"]["target"].get<std::string>(),
+        "%T30.1");
+}
+
+TEST(BejEncoderDeferredBindingTest, IdlessMarkerRoundTrips)
+{
+    auto inputsOrErr = loadInputs(driveOemTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+
+    // Map only the id-less %C token to the DriveOEM @odata.id value, so the
+    // encoder rewrites that leaf to "%C". Exercises a marker carrying no
+    // resource id end to end.
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::chassis()] = "/redfish/v1/drives/1";
+
+    libbej::BejEncoderJson encoder;
+    encoder.encode(&dictionaries, bejMajorSchemaClass, createDriveOem(),
+                   bindings);
+    std::vector<uint8_t> outputBuffer = encoder.getOutput();
+
+    // Enabled: %C resolves back to the bound value.
+    libbej::BejDecoderJson decoder;
+    EXPECT_THAT(decoder.decode(dictionaries, std::span(outputBuffer), bindings),
+                0);
+    nlohmann::json bound = nlohmann::json::parse(decoder.getOutput());
+    EXPECT_EQ(bound["@odata.id"].get<std::string>(), "/redfish/v1/drives/1");
+
+    // Disabled: the id-less placeholder is preserved raw as "%C".
+    libbej::BejDecoderJson rawDecoder;
+    EXPECT_THAT(rawDecoder.decode(dictionaries, std::span(outputBuffer)), 0);
+    nlohmann::json raw = nlohmann::json::parse(rawDecoder.getOutput());
+    EXPECT_EQ(raw["@odata.id"].get<std::string>(), "%C");
+}
+
+TEST(BejEncoderDeferredBindingTest, SystemMarkerRoundTrips)
+{
+    auto inputsOrErr = loadInputs(driveOemTestFiles);
+    ASSERT_TRUE(inputsOrErr);
+
+    BejDictionaries dictionaries = {
+        .schemaDictionary = inputsOrErr->schemaDictionary,
+        .schemaDictionarySize = inputsOrErr->schemaDictionarySize,
+        .annotationDictionary = inputsOrErr->annotationDictionary,
+        .annotationDictionarySize = inputsOrErr->annotationDictionarySize,
+        .errorDictionary = inputsOrErr->errorDictionary,
+        .errorDictionarySize = inputsOrErr->errorDictionarySize,
+    };
+
+    // Map only the id-less %S token to the DriveOEM @odata.id value, so the
+    // encoder rewrites that leaf to "%S".
+    BejDeferredBindingMap bindings;
+    bindings[bejBinding::system()] = "/redfish/v1/drives/1";
+
+    libbej::BejEncoderJson encoder;
+    encoder.encode(&dictionaries, bejMajorSchemaClass, createDriveOem(),
+                   bindings);
+    std::vector<uint8_t> outputBuffer = encoder.getOutput();
+
+    // Enabled: %S resolves back to the bound value.
+    libbej::BejDecoderJson decoder;
+    EXPECT_THAT(decoder.decode(dictionaries, std::span(outputBuffer), bindings),
+                0);
+    nlohmann::json bound = nlohmann::json::parse(decoder.getOutput());
+    EXPECT_EQ(bound["@odata.id"].get<std::string>(), "/redfish/v1/drives/1");
+
+    // Disabled: the id-less placeholder is preserved raw as "%S".
+    libbej::BejDecoderJson rawDecoder;
+    EXPECT_THAT(rawDecoder.decode(dictionaries, std::span(outputBuffer)), 0);
+    nlohmann::json raw = nlohmann::json::parse(rawDecoder.getOutput());
+    EXPECT_EQ(raw["@odata.id"].get<std::string>(), "%S");
+}
+
 /**
  * TODO: Add more test cases.
  */
diff --git a/test/dictionaries/network_adapter_dict.bin b/test/dictionaries/network_adapter_dict.bin
new file mode 100644
index 0000000..b007438
--- /dev/null
+++ b/test/dictionaries/network_adapter_dict.bin
Binary files differ
diff --git a/test/encoded/network_adapter_enc.bin b/test/encoded/network_adapter_enc.bin
new file mode 100644
index 0000000..71942c3
--- /dev/null
+++ b/test/encoded/network_adapter_enc.bin
Binary files differ
diff --git a/test/json/network_adapter.json b/test/json/network_adapter.json
new file mode 100644
index 0000000..af852c8
--- /dev/null
+++ b/test/json/network_adapter.json
@@ -0,0 +1,71 @@
+{
+    "@odata.etag": "E98A4709",
+    "@odata.id": "%L1",
+    "@odata.type": "#NetworkAdapter.v1_3_0.NetworkAdapter",
+    "Id": "%I1",
+    "Actions": {
+        "ResetSettingsToDefault": {}
+    },
+    "Controllers": [
+        {
+            "ControllerCapabilities": {
+                "DataCenterBridging": {
+                    "Capable": true
+                },
+                "NetworkDeviceFunctionCount": 2,
+                "NetworkPortCount": 2,
+                "VirtualizationOffload": {
+                    "SRIOV": {
+                        "SRIOVVEPACapable": true
+                    },
+                    "VirtualFunction": {
+                        "DeviceMaxCount": 256,
+                        "MinAssignmentGroupSize": 1,
+                        "NetworkPortMaxCount": 256
+                    }
+                }
+            },
+            "FirmwarePackageVersion": "5.1.9",
+            "Links": {
+                "NetworkDeviceFunctions": [
+                    {
+                        "@odata.id": "%L200"
+                    },
+                    {
+                        "@odata.id": "%L201"
+                    }
+                ],
+                "NetworkPorts": [
+                    {
+                        "@odata.id": "%L100"
+                    },
+                    {
+                        "@odata.id": "%L101"
+                    }
+                ]
+            },
+            "PCIeInterface": {
+                "LanesInUse": 4,
+                "MaxLanes": 8,
+                "MaxPCIeType": "Gen2",
+                "PCIeType": "Gen4"
+            }
+        }
+    ],
+    "Manufacturer": "Intel Corp.",
+    "Model": "E810-XXVAM2",
+    "Name": "E810 Network Adapter",
+    "NetworkDeviceFunctions": {
+        "@odata.id": "%L20"
+    },
+    "NetworkPorts": {
+        "@odata.id": "%L10"
+    },
+    "PartNumber": "K71121-002",
+    "SKU": "XXVAM2",
+    "SerialNumber": "30B1A8FFFF9196B4",
+    "Status": {
+        "State": "Starting",
+        "HealthRollup": "OK"
+    }
+}
diff --git a/test/json/network_adapter_resolved.json b/test/json/network_adapter_resolved.json
new file mode 100644
index 0000000..5973213
--- /dev/null
+++ b/test/json/network_adapter_resolved.json
@@ -0,0 +1,71 @@
+{
+    "@odata.etag": "E98A4709",
+    "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1",
+    "@odata.type": "#NetworkAdapter.v1_3_0.NetworkAdapter",
+    "Id": "RDE1_1",
+    "Actions": {
+        "ResetSettingsToDefault": {}
+    },
+    "Controllers": [
+        {
+            "ControllerCapabilities": {
+                "DataCenterBridging": {
+                    "Capable": true
+                },
+                "NetworkDeviceFunctionCount": 2,
+                "NetworkPortCount": 2,
+                "VirtualizationOffload": {
+                    "SRIOV": {
+                        "SRIOVVEPACapable": true
+                    },
+                    "VirtualFunction": {
+                        "DeviceMaxCount": 256,
+                        "MinAssignmentGroupSize": 1,
+                        "NetworkPortMaxCount": 256
+                    }
+                }
+            },
+            "FirmwarePackageVersion": "5.1.9",
+            "Links": {
+                "NetworkDeviceFunctions": [
+                    {
+                        "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/NetworkDeviceFunctions/RDE1_200"
+                    },
+                    {
+                        "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/NetworkDeviceFunctions/RDE1_201"
+                    }
+                ],
+                "NetworkPorts": [
+                    {
+                        "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/Ports/RDE1_100"
+                    },
+                    {
+                        "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/Ports/RDE1_101"
+                    }
+                ]
+            },
+            "PCIeInterface": {
+                "LanesInUse": 4,
+                "MaxLanes": 8,
+                "MaxPCIeType": "Gen2",
+                "PCIeType": "Gen4"
+            }
+        }
+    ],
+    "Manufacturer": "Intel Corp.",
+    "Model": "E810-XXVAM2",
+    "Name": "E810 Network Adapter",
+    "NetworkDeviceFunctions": {
+        "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/NetworkDeviceFunctions"
+    },
+    "NetworkPorts": {
+        "@odata.id": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/Ports"
+    },
+    "PartNumber": "K71121-002",
+    "SKU": "XXVAM2",
+    "SerialNumber": "30B1A8FFFF9196B4",
+    "Status": {
+        "State": "Starting",
+        "HealthRollup": "OK"
+    }
+}
diff --git a/test/pdr/network_adapter_pdr.json b/test/pdr/network_adapter_pdr.json
new file mode 100644
index 0000000..1e8427c
--- /dev/null
+++ b/test/pdr/network_adapter_pdr.json
@@ -0,0 +1,10 @@
+{
+    "%L1": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1",
+    "%I1": "RDE1_1",
+    "%L10": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/Ports",
+    "%L20": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/NetworkDeviceFunctions",
+    "%L100": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/Ports/RDE1_100",
+    "%L101": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/Ports/RDE1_101",
+    "%L200": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/NetworkDeviceFunctions/RDE1_200",
+    "%L201": "/redfish/v1/Chassis/RDE/NetworkAdapters/RDE1_1/NetworkDeviceFunctions/RDE1_201"
+}