From ab6de29d80c831f03fd0c9b25c56d82ac7023770 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Tue, 13 Oct 2026 22:20:22 +0300 Subject: [PATCH 1/3] feat(config): add runtime binding contract Add typed-erased programmatic binding storage, resolver and logger factory interfaces, and atomic runtime instantiation for materialized plans. Required bindings are checked before factory calls, diagnostics are rebased to document paths, and caller-visible logger sets remain unchanged on failure. --- CHANGELOG.md | 3 + include/logit_cpp/logit.hpp | 1 + include/logit_cpp/logit/configuration.hpp | 6 +- .../logit_cpp/logit/configuration_runtime.hpp | 143 ++++++++++++++++++ tests/configuration_contract_test.cpp | 70 +++++++++ 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 include/logit_cpp/logit/configuration_runtime.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 9803506..62d233a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to this project will be documented in this file. follow-up layers. - Added side-effect-free built-in configuration validation and default materialization for file, memory, MDBX, OTLP, and Prometheus plans. +- Added a binding/resolution and atomic runtime-instantiation contract for + materialized configuration plans; parser frontends and hot reload remain + outside this layer. ### Fixed diff --git a/include/logit_cpp/logit.hpp b/include/logit_cpp/logit.hpp index 643e8a4..313953e 100644 --- a/include/logit_cpp/logit.hpp +++ b/include/logit_cpp/logit.hpp @@ -16,6 +16,7 @@ #include "logit/utils.hpp" #include "logit/formatter.hpp" #include "logit/loggers.hpp" +#include "logit/configuration_runtime.hpp" #include "logit/Logger.hpp" #include "logit/log_macros.hpp" diff --git a/include/logit_cpp/logit/configuration.hpp b/include/logit_cpp/logit/configuration.hpp index 0c47005..f0660c8 100644 --- a/include/logit_cpp/logit/configuration.hpp +++ b/include/logit_cpp/logit/configuration.hpp @@ -30,7 +30,9 @@ namespace logit { MissingProgrammaticBinding, UnknownField, BackendValidationFailed, - MaterializationFailed + MaterializationFailed, + BindingResolutionFailed, + InstantiationFailed }; inline const char* configuration_error_code_name(ConfigurationErrorCode code) { @@ -47,6 +49,8 @@ namespace logit { case ConfigurationErrorCode::UnknownField: return "unknown_field"; case ConfigurationErrorCode::BackendValidationFailed: return "backend_validation_failed"; case ConfigurationErrorCode::MaterializationFailed: return "materialization_failed"; + case ConfigurationErrorCode::BindingResolutionFailed: return "binding_resolution_failed"; + case ConfigurationErrorCode::InstantiationFailed: return "instantiation_failed"; } return "unknown_configuration_error"; } diff --git a/include/logit_cpp/logit/configuration_runtime.hpp b/include/logit_cpp/logit/configuration_runtime.hpp new file mode 100644 index 0000000..06a3730 --- /dev/null +++ b/include/logit_cpp/logit/configuration_runtime.hpp @@ -0,0 +1,143 @@ +#pragma once +#ifndef LOGIT_CPP_HEADER_LOGIT_CPP_LOGIT_CONFIGURATION_RUNTIME_HPP_INCLUDED +#define LOGIT_CPP_HEADER_LOGIT_CPP_LOGIT_CONFIGURATION_RUNTIME_HPP_INCLUDED + +/// \file configuration_runtime.hpp +/// \brief Binding and runtime-instantiation contract for materialized plans. + +#include "configuration.hpp" +#include "loggers.hpp" + +#include +#include +#include +#include + +namespace logit { + + /// \class ConfigurationBindingSet + /// \brief Typed-erased programmatic resources resolved for one logger. + /// + /// Bindings deliberately remain outside the parser-neutral document. A + /// resolver and its factory agree on the concrete type associated with each + /// name; the configuration layer only owns the lifetime of the shared value. + class ConfigurationBindingSet { + public: + bool empty() const { return m_values.empty(); } + + bool contains(const std::string& name) const { + return m_values.find(name) != m_values.end(); + } + + template + void set(const std::string& name, const std::shared_ptr& value) { + m_values[name] = value; + } + + template + std::shared_ptr get(const std::string& name) const { + std::map>::const_iterator it = m_values.find(name); + if (it == m_values.end()) return std::shared_ptr(); + return std::static_pointer_cast(it->second); + } + + private: + std::map> m_values; + }; + + /// \class ConfigurationBindingResolver + /// \brief Resolves required programmatic bindings without constructing a logger. + class ConfigurationBindingResolver { + public: + virtual ~ConfigurationBindingResolver() {} + + /// \brief Resolve bindings for one materialized logger. + /// + /// Diagnostic paths are relative to the logger root. The document layer + /// prefixes them with `loggers[N]`. + virtual bool resolve(const ConfigurationMaterializedLogger& logger, + ConfigurationBindingSet& bindings, + ConfigurationDiagnostics& diagnostics) const = 0; + }; + + /// \class ConfigurationLoggerFactory + /// \brief Creates one runtime logger after bindings have been resolved. + class ConfigurationLoggerFactory { + public: + virtual ~ConfigurationLoggerFactory() {} + + /// \brief Instantiate one logger from a complete plan entry. + /// + /// Returning an empty pointer is a failed instantiation. Diagnostic + /// paths are relative to the logger root and are rebased by the document + /// layer. + virtual std::shared_ptr instantiate( + const ConfigurationMaterializedLogger& logger, + const ConfigurationBindingSet& bindings, + ConfigurationDiagnostics& diagnostics) const = 0; + }; + + /// \brief Resolve bindings and instantiate a complete logger set atomically. + inline bool instantiate_configuration( + const ConfigurationPlan& plan, + const ConfigurationBindingResolver& resolver, + const ConfigurationLoggerFactory& factory, + std::vector>& loggers, + ConfigurationDiagnostics& diagnostics) { + diagnostics = ConfigurationDiagnostics(); + std::vector> candidate; + candidate.reserve(plan.loggers.size()); + + for (std::size_t index = 0; index < plan.loggers.size(); ++index) { + const ConfigurationMaterializedLogger& entry = plan.loggers[index]; + ConfigurationBindingSet bindings; + ConfigurationDiagnostics local_diagnostics; + const bool resolved = resolver.resolve(entry, bindings, local_diagnostics); + + bool missing_binding = false; + for (std::vector::const_iterator required = + entry.required_bindings.begin(); + required != entry.required_bindings.end(); ++required) { + if (!bindings.contains(*required)) { + local_diagnostics.add(ConfigurationErrorCode::MissingProgrammaticBinding, + "binding." + *required, + "Required programmatic binding was not resolved"); + missing_binding = true; + } + } + + const std::string prefix = "loggers[" + std::to_string(index) + "]"; + if (!resolved || !local_diagnostics.empty() || missing_binding) { + if (local_diagnostics.empty()) { + diagnostics.add(ConfigurationErrorCode::BindingResolutionFailed, + prefix + ".binding", + "Programmatic bindings could not be resolved"); + } else { + diagnostics.append_rebased(local_diagnostics, prefix); + } + return false; + } + + ConfigurationDiagnostics factory_diagnostics; + std::shared_ptr runtime_logger = + factory.instantiate(entry, bindings, factory_diagnostics); + if (!runtime_logger || !factory_diagnostics.empty()) { + if (factory_diagnostics.empty()) { + diagnostics.add(ConfigurationErrorCode::InstantiationFailed, + prefix, + "Logger factory could not instantiate the plan entry"); + } else { + diagnostics.append_rebased(factory_diagnostics, prefix); + } + return false; + } + candidate.push_back(runtime_logger); + } + + loggers = candidate; + return true; + } + +} // namespace logit + +#endif // LOGIT_CPP_HEADER_LOGIT_CPP_LOGIT_CONFIGURATION_RUNTIME_HPP_INCLUDED diff --git a/tests/configuration_contract_test.cpp b/tests/configuration_contract_test.cpp index aa0a708..ba0b8e8 100644 --- a/tests/configuration_contract_test.cpp +++ b/tests/configuration_contract_test.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -53,6 +54,36 @@ struct RecordingMaterializer : logit::ConfigurationMaterializer { } }; +struct RecordingBindingResolver : logit::ConfigurationBindingResolver { + bool provide_binding = true; + mutable int calls = 0; + + bool resolve(const logit::ConfigurationMaterializedLogger& logger, + logit::ConfigurationBindingSet& bindings, + logit::ConfigurationDiagnostics&) const override { + ++calls; + if (provide_binding && !logger.required_bindings.empty()) { + bindings.set("on_payload", std::shared_ptr(new int(1))); + } + return true; + } +}; + +struct RecordingLoggerFactory : logit::ConfigurationLoggerFactory { + mutable int calls = 0; + int fail_at = -1; + + std::shared_ptr instantiate( + const logit::ConfigurationMaterializedLogger&, + const logit::ConfigurationBindingSet& bindings, + logit::ConfigurationDiagnostics&) const override { + ++calls; + (void)bindings.get("on_payload"); + if (fail_at == calls) return std::shared_ptr(); + return std::shared_ptr(new logit::MemoryLogger()); + } +}; + logit::ConfigurationDocument valid_document() { logit::ConfigurationDocument document; document.schema_version = 1; @@ -356,6 +387,44 @@ void test_optional_format_defaults_merge_recursively() { #endif } +void test_runtime_binding_and_instantiation_are_atomic() { + logit::ConfigurationPlan plan; + plan.schema_version = 1; + logit::ConfigurationMaterializedLogger first; + first.id = "callback-entry"; + first.required_bindings.push_back("on_payload"); + plan.loggers.push_back(first); + + RecordingBindingResolver resolver; + RecordingLoggerFactory factory; + std::vector> loggers; + std::shared_ptr sentinel(new logit::MemoryLogger()); + loggers.push_back(sentinel); + logit::ConfigurationDiagnostics diagnostics; + assert(logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); + assert(diagnostics.empty()); + assert(loggers.size() == 1); + assert(loggers[0] != sentinel); + + resolver.provide_binding = false; + loggers.clear(); + loggers.push_back(sentinel); + assert(!logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); + assert(diagnostics.items()[0].path == "loggers[0].binding.on_payload"); + assert(loggers.size() == 1); + assert(loggers[0] == sentinel); + + resolver.provide_binding = true; + logit::ConfigurationMaterializedLogger second = first; + second.id = "second"; + plan.loggers.push_back(second); + factory.fail_at = 2; + assert(!logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::InstantiationFailed)); + assert(loggers.size() == 1); + assert(loggers[0] == sentinel); +} + } // namespace int main() { @@ -371,5 +440,6 @@ int main() { test_materializer_diagnostics_are_rebased_to_logger_index(); test_callback_backends_record_required_bindings_when_available(); test_optional_format_defaults_merge_recursively(); + test_runtime_binding_and_instantiation_are_atomic(); return 0; } From a90cad37157f60f9d37a3f9f2aeeed4104971efd Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Tue, 13 Oct 2026 23:54:45 +0300 Subject: [PATCH 2/3] fix(config): harden runtime binding lifecycle Reject null required bindings, record type_index metadata for safe typed retrieval, and resolve every logger binding before invoking any factory. Clarify that only caller-visible logger publication is atomic while factory side effects remain non-transactional, and extend regression coverage for null, type mismatch, and late binding failures. --- CHANGELOG.md | 6 +- .../logit_cpp/logit/configuration_runtime.hpp | 58 ++++++++++++++----- tests/configuration_contract_test.cpp | 42 ++++++++++++-- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d233a..4c09ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,9 @@ All notable changes to this project will be documented in this file. follow-up layers. - Added side-effect-free built-in configuration validation and default materialization for file, memory, MDBX, OTLP, and Prometheus plans. -- Added a binding/resolution and atomic runtime-instantiation contract for - materialized configuration plans; parser frontends and hot reload remain - outside this layer. +- Added a binding/resolution and atomic caller-visible publication contract for + materialized runtime logger plans; factory side effects are not transactional, + and parser frontends and hot reload remain outside this layer. ### Fixed diff --git a/include/logit_cpp/logit/configuration_runtime.hpp b/include/logit_cpp/logit/configuration_runtime.hpp index 06a3730..45b1606 100644 --- a/include/logit_cpp/logit/configuration_runtime.hpp +++ b/include/logit_cpp/logit/configuration_runtime.hpp @@ -11,12 +11,13 @@ #include #include #include +#include #include namespace logit { /// \class ConfigurationBindingSet - /// \brief Typed-erased programmatic resources resolved for one logger. + /// \brief Type-erased programmatic resources resolved for one logger. /// /// Bindings deliberately remain outside the parser-neutral document. A /// resolver and its factory agree on the concrete type associated with each @@ -26,23 +27,36 @@ namespace logit { bool empty() const { return m_values.empty(); } bool contains(const std::string& name) const { - return m_values.find(name) != m_values.end(); + std::map::const_iterator it = m_values.find(name); + return it != m_values.end() && static_cast(it->second.value); } template void set(const std::string& name, const std::shared_ptr& value) { - m_values[name] = value; + m_values[name] = Entry(value, std::type_index(typeid(T))); } template std::shared_ptr get(const std::string& name) const { - std::map>::const_iterator it = m_values.find(name); - if (it == m_values.end()) return std::shared_ptr(); - return std::static_pointer_cast(it->second); + std::map::const_iterator it = m_values.find(name); + if (it == m_values.end() || !it->second.value || + it->second.type != std::type_index(typeid(T))) { + return std::shared_ptr(); + } + return std::static_pointer_cast(it->second.value); } private: - std::map> m_values; + struct Entry { + Entry() : value(), type(typeid(void)) {} + Entry(const std::shared_ptr& value, const std::type_index& type) + : value(value), type(type) {} + + std::shared_ptr value; + std::type_index type; + }; + + std::map m_values; }; /// \class ConfigurationBindingResolver @@ -62,6 +76,8 @@ namespace logit { /// \class ConfigurationLoggerFactory /// \brief Creates one runtime logger after bindings have been resolved. + /// Factory side effects are owned by the caller and are not transactional; + /// only publication of the caller-visible logger vector is atomic. class ConfigurationLoggerFactory { public: virtual ~ConfigurationLoggerFactory() {} @@ -77,7 +93,13 @@ namespace logit { ConfigurationDiagnostics& diagnostics) const = 0; }; - /// \brief Resolve bindings and instantiate a complete logger set atomically. + /// \brief Resolve bindings and publish a complete logger set atomically. + /// + /// The caller-visible logger vector is updated only after every binding has + /// resolved and every factory call has returned a logger. Factories may + /// still perform external side effects while constructing instances; those + /// effects are not transactional and are not rolled back if a later factory + /// fails. inline bool instantiate_configuration( const ConfigurationPlan& plan, const ConfigurationBindingResolver& resolver, @@ -85,9 +107,9 @@ namespace logit { std::vector>& loggers, ConfigurationDiagnostics& diagnostics) { diagnostics = ConfigurationDiagnostics(); - std::vector> candidate; - candidate.reserve(plan.loggers.size()); - + std::vector resolved_bindings; + resolved_bindings.reserve(plan.loggers.size()); + bool bindings_ok = true; for (std::size_t index = 0; index < plan.loggers.size(); ++index) { const ConfigurationMaterializedLogger& entry = plan.loggers[index]; ConfigurationBindingSet bindings; @@ -108,6 +130,7 @@ namespace logit { const std::string prefix = "loggers[" + std::to_string(index) + "]"; if (!resolved || !local_diagnostics.empty() || missing_binding) { + bindings_ok = false; if (local_diagnostics.empty()) { diagnostics.add(ConfigurationErrorCode::BindingResolutionFailed, prefix + ".binding", @@ -115,12 +138,21 @@ namespace logit { } else { diagnostics.append_rebased(local_diagnostics, prefix); } - return false; } + resolved_bindings.push_back(bindings); + } + + if (!bindings_ok) return false; + + std::vector> candidate; + candidate.reserve(plan.loggers.size()); + for (std::size_t index = 0; index < plan.loggers.size(); ++index) { + const ConfigurationMaterializedLogger& entry = plan.loggers[index]; + const std::string prefix = "loggers[" + std::to_string(index) + "]"; ConfigurationDiagnostics factory_diagnostics; std::shared_ptr runtime_logger = - factory.instantiate(entry, bindings, factory_diagnostics); + factory.instantiate(entry, resolved_bindings[index], factory_diagnostics); if (!runtime_logger || !factory_diagnostics.empty()) { if (factory_diagnostics.empty()) { diagnostics.add(ConfigurationErrorCode::InstantiationFailed, diff --git a/tests/configuration_contract_test.cpp b/tests/configuration_contract_test.cpp index ba0b8e8..988cd37 100644 --- a/tests/configuration_contract_test.cpp +++ b/tests/configuration_contract_test.cpp @@ -56,14 +56,24 @@ struct RecordingMaterializer : logit::ConfigurationMaterializer { struct RecordingBindingResolver : logit::ConfigurationBindingResolver { bool provide_binding = true; + bool provide_null_binding = false; + bool provide_wrong_type = false; + int missing_at = -1; mutable int calls = 0; bool resolve(const logit::ConfigurationMaterializedLogger& logger, logit::ConfigurationBindingSet& bindings, logit::ConfigurationDiagnostics&) const override { ++calls; - if (provide_binding && !logger.required_bindings.empty()) { - bindings.set("on_payload", std::shared_ptr(new int(1))); + if (provide_binding && !logger.required_bindings.empty() && + (missing_at < 0 || calls != missing_at)) { + if (provide_null_binding) { + bindings.set("on_payload", std::shared_ptr()); + } else if (provide_wrong_type) { + bindings.set("on_payload", std::shared_ptr(new std::string("wrong"))); + } else { + bindings.set("on_payload", std::shared_ptr(new int(1))); + } } return true; } @@ -415,14 +425,38 @@ void test_runtime_binding_and_instantiation_are_atomic() { assert(loggers[0] == sentinel); resolver.provide_binding = true; + resolver.provide_null_binding = true; + factory.calls = 0; + assert(!logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::MissingProgrammaticBinding)); + assert(factory.calls == 0); + + resolver.provide_null_binding = false; + resolver.provide_wrong_type = true; + logit::ConfigurationBindingSet bindings; + bindings.set("value", std::shared_ptr(new int(1))); + assert(bindings.get("value")); + assert(!bindings.get("value")); + resolver.provide_wrong_type = false; + + resolver.provide_binding = true; + factory.calls = 0; logit::ConfigurationMaterializedLogger second = first; second.id = "second"; plan.loggers.push_back(second); - factory.fail_at = 2; + resolver.calls = 0; + resolver.missing_at = 2; assert(!logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); - assert(diagnostics.has_code(logit::ConfigurationErrorCode::InstantiationFailed)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::MissingProgrammaticBinding)); + assert(factory.calls == 0); assert(loggers.size() == 1); assert(loggers[0] == sentinel); + + resolver.provide_binding = true; + resolver.missing_at = -1; + factory.fail_at = 2; + assert(!logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::InstantiationFailed)); } } // namespace From 8c65b12cefb76e9bd553f0672d7ce49264a81118 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Wed, 14 Oct 2026 00:01:44 +0300 Subject: [PATCH 3/3] test(config): enforce factory binding type failures Document that factories must retrieve required bindings with the expected type and fail closed on mismatch. Extend the runtime contract regression to verify a wrong typed binding reaches the factory, produces a diagnostic, and leaves the caller-visible logger set unchanged. --- .../logit_cpp/logit/configuration_runtime.hpp | 4 ++++ tests/configuration_contract_test.cpp | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/include/logit_cpp/logit/configuration_runtime.hpp b/include/logit_cpp/logit/configuration_runtime.hpp index 45b1606..ea05cbc 100644 --- a/include/logit_cpp/logit/configuration_runtime.hpp +++ b/include/logit_cpp/logit/configuration_runtime.hpp @@ -78,6 +78,10 @@ namespace logit { /// \brief Creates one runtime logger after bindings have been resolved. /// Factory side effects are owned by the caller and are not transactional; /// only publication of the caller-visible logger vector is atomic. + /// A factory owns the concrete binding types it expects and must retrieve + /// required values through `ConfigurationBindingSet::get()`. If a + /// required value has the wrong type, the factory must fail closed by + /// adding a diagnostic or returning an empty logger pointer. class ConfigurationLoggerFactory { public: virtual ~ConfigurationLoggerFactory() {} diff --git a/tests/configuration_contract_test.cpp b/tests/configuration_contract_test.cpp index 988cd37..413199d 100644 --- a/tests/configuration_contract_test.cpp +++ b/tests/configuration_contract_test.cpp @@ -86,9 +86,14 @@ struct RecordingLoggerFactory : logit::ConfigurationLoggerFactory { std::shared_ptr instantiate( const logit::ConfigurationMaterializedLogger&, const logit::ConfigurationBindingSet& bindings, - logit::ConfigurationDiagnostics&) const override { + logit::ConfigurationDiagnostics& diagnostics) const override { ++calls; - (void)bindings.get("on_payload"); + if (!bindings.get("on_payload")) { + diagnostics.add(logit::ConfigurationErrorCode::InstantiationFailed, + "binding.on_payload", + "Test factory rejected a binding with the wrong type"); + return std::shared_ptr(); + } if (fail_at == calls) return std::shared_ptr(); return std::shared_ptr(new logit::MemoryLogger()); } @@ -439,6 +444,18 @@ void test_runtime_binding_and_instantiation_are_atomic() { assert(!bindings.get("value")); resolver.provide_wrong_type = false; + resolver.provide_binding = true; + resolver.provide_wrong_type = true; + factory.calls = 0; + loggers.clear(); + loggers.push_back(sentinel); + assert(!logit::instantiate_configuration(plan, resolver, factory, loggers, diagnostics)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::InstantiationFailed)); + assert(factory.calls == 1); + assert(loggers.size() == 1); + assert(loggers[0] == sentinel); + resolver.provide_wrong_type = false; + resolver.provide_binding = true; factory.calls = 0; logit::ConfigurationMaterializedLogger second = first;