refactor: move firmware workspace under firmware

This commit is contained in:
Onur Özkan committed 2026-08-10 16:58:55 +03:00
1 parent 153b0bf262
commit f03fc119a4
222 files changed
+56 -13

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
[package]
name = "kaptan-config"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-config-derive = { path = "../kaptan-config-derive" }
kaptan-log = { path = "../kaptan-log" }
+85
View File
@@ -0,0 +1,85 @@
//! Bounded, transport-neutral configuration validation errors.
use crate::FieldId;
/// The invariant violated by a configuration field or complete profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u16)]
pub enum ValidationRule {
/// A floating-point value was NaN or infinite.
NonFinite = 1,
/// A value required to be nonzero was zero.
Zero = 2,
/// A value was below its inclusive lower bound.
BelowMinimum = 3,
/// A value exceeded its inclusive upper bound.
AboveMaximum = 4,
/// Individually valid fields do not form a valid complete configuration.
Inconsistent = 5,
/// The candidate requires a board capability that is unavailable.
UnsupportedCapability = 6,
/// The encoded snapshot format cannot be used by this firmware.
UnsupportedFormat = 7,
}
impl ValidationRule {
/// Stable numeric protocol/error-catalog code.
pub const fn code(self) -> u16 {
self as u16
}
}
/// An allocation-free validation issue.
///
/// The control station resolves numeric fields and rules through the schema
/// metadata. `related` identifies the other field for relational invariants.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigViolation {
primary: Option<FieldId>,
related: Option<FieldId>,
rule: ValidationRule,
}
impl ConfigViolation {
/// Constructs a field-local violation.
pub const fn field(primary: FieldId, rule: ValidationRule) -> Self {
Self {
primary: Some(primary),
related: None,
rule,
}
}
/// Constructs a relational violation.
pub const fn related(primary: FieldId, related: FieldId, rule: ValidationRule) -> Self {
Self {
primary: Some(primary),
related: Some(related),
rule,
}
}
/// Constructs a profile-level violation with no scalar identity.
pub const fn global(rule: ValidationRule) -> Self {
Self {
primary: None,
related: None,
rule,
}
}
/// First field associated with this issue.
pub const fn primary(self) -> Option<FieldId> {
self.primary
}
/// Related field for cross-field validation.
pub const fn related_field(self) -> Option<FieldId> {
self.related
}
/// Violated invariant.
pub const fn rule(self) -> ValidationRule {
self.rule
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Transport-neutral typed configuration fields and descriptors.
use crate::{ConfigViolation, ValidationRule};
use kaptan_log::KaptanError;
/// A stable, schema-local identity for one editable configuration field.
///
/// IDs are never reused within a schema generation. They are compact wire
/// identities; display strings belong to metadata and diagnostics only.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
#[repr(transparent)]
pub struct FieldId(u16);
impl FieldId {
/// Creates a stable field identity.
pub const fn new(value: u16) -> Self {
assert!(
value != 0 && value != u16::MAX,
"reserved configuration field id"
);
Self(value)
}
/// Parses a field identity received from an untrusted transport.
pub const fn try_new(value: u16) -> Option<Self> {
if value == 0 || value == u16::MAX {
None
} else {
Some(Self(value))
}
}
/// Returns the transport representation.
pub const fn get(self) -> u16 {
self.0
}
}
/// The scalar representation of a configuration value.
///
/// The variants deliberately preserve native widths: durations, for example,
/// remain `u64` microseconds instead of being lossy display-unit conversions.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ConfigValue {
/// Boolean value.
Bool(bool),
/// Signed 32-bit integer.
I32(i32),
/// Unsigned 32-bit integer.
U32(u32),
/// Unsigned 64-bit integer.
U64(u64),
/// IEEE-754 single-precision value.
F32(f32),
/// IEEE-754 double-precision value.
F64(f64),
}
impl ConfigValue {
/// The declared value kind.
pub const fn kind(self) -> ValueKind {
match self {
Self::Bool(_) => ValueKind::Bool,
Self::I32(_) => ValueKind::I32,
Self::U32(_) => ValueKind::U32,
Self::U64(_) => ValueKind::U64,
Self::F32(_) => ValueKind::F32,
Self::F64(_) => ValueKind::F64,
}
}
/// Canonical scalar bits for compact transport encodings.
pub const fn raw_bits(self) -> u64 {
match self {
Self::Bool(value) => value as u64,
Self::I32(value) => value as u32 as u64,
Self::U32(value) => value as u64,
Self::U64(value) => value,
Self::F32(value) => value.to_bits() as u64,
Self::F64(value) => value.to_bits(),
}
}
}
/// The scalar kind carried in a descriptor or typed protocol message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ValueKind {
/// Boolean.
Bool = 1,
/// Signed 32-bit integer.
I32 = 2,
/// Unsigned 32-bit integer.
U32 = 3,
/// Unsigned 64-bit integer.
U64 = 4,
/// Single-precision float.
F32 = 5,
/// Double-precision float.
F64 = 6,
}
/// When a committed field may affect the active runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ApplyPolicy {
/// Reserved for individually proven live-safe changes.
Live = 1,
/// Reserved for controlled output-inhibited reconfiguration.
DisarmedReset = 2,
/// P1 policy: the field takes effect only at the next boot.
RestartRequired = 3,
}
/// Static metadata for one configuration field.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldDescriptor {
/// Stable schema-local identity.
pub id: FieldId,
/// GCS display key; never used as a wire identity.
pub display_key: &'static str,
/// Scalar storage type.
pub kind: ValueKind,
/// Inclusive lower bound when the value is ordered.
pub minimum: Option<ConfigValue>,
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// Firmware-build default.
pub default: ConfigValue,
/// Human-readable unit for schema metadata.
pub unit: &'static str,
/// Activation policy advertised to a control station.
pub apply_policy: ApplyPolicy,
}
/// Failure while changing one transaction field.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum FieldWriteError {
/// No descriptor exposes the requested identity.
UnknownField(FieldId),
/// The supplied scalar type differs from the descriptor type.
WrongType {
/// Requested field.
field: FieldId,
/// Expected descriptor kind.
expected: ValueKind,
/// Supplied scalar kind.
actual: ValueKind,
},
/// A locally validated scalar violated a stable rule.
Violation(ConfigViolation),
}
impl FieldWriteError {
/// Returns the typed local validation issue, if this was one.
pub const fn violation(self) -> Option<ConfigViolation> {
match self {
Self::Violation(violation) => Some(violation),
Self::UnknownField(_) | Self::WrongType { .. } => None,
}
}
/// Returns a field-local non-finite error.
pub const fn non_finite(field: FieldId) -> Self {
Self::Violation(ConfigViolation::field(field, ValidationRule::NonFinite))
}
}
/// Vehicle-specific editable candidate model.
///
/// Implementations expose every operator-editable scalar through the static
/// descriptor registry. A field write performs only local validation; complete
/// relational and capability validation happens through [`Self::validate`].
pub trait ConfigModel: Clone {
/// Immutable configuration consumed by the runtime at boot.
type Active;
/// Immutable compiled board/vehicle capability contract.
type Context;
/// Exact identifier for the current externally transferable schema.
const SCHEMA_ID: [u8; 16];
/// Returns the descriptor at a stable zero-based schema index.
fn descriptor(index: u16) -> Option<&'static FieldDescriptor>;
/// Number of fields exposed by the compiled schema.
fn field_count() -> u16;
/// Returns a candidate value by stable ID.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Returns one value from the immutable boot-active view.
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue>;
/// Applies exactly one locally valid scalar to this candidate.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Fully validates and converts the candidate to an immutable active view.
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation>;
}
+25
View File
@@ -0,0 +1,25 @@
//! Runtime-configuration primitives shared by vehicle-specific schemas.
//!
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. CBOR storage and MAVLink transport are separate adapters.
#![no_std]
extern crate self as kaptan_config;
pub mod error;
pub mod field;
pub mod lifecycle;
pub mod storage;
pub mod validation;
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
};
pub use kaptan_config_derive::ConfigValidate;
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use storage::{Generation, ProfileStore};
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields};
@@ -0,0 +1,420 @@
//! Transactional committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
Generation, ProfileStore,
};
use kaptan_log::KaptanError;
/// Observable state of the bounded configuration transaction slot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigState {
/// The boot-active and committed snapshots match and no edit is open.
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after reboot.
RestartRequired,
}
/// Failure while recovering a boot configuration from a store.
#[derive(Debug, KaptanError)]
pub enum StartupError<E> {
/// The storage backend could not provide a committed record.
Store(E),
/// A committed record did not validate against this firmware's schema.
InvalidPersisted(ConfigViolation),
}
/// Failure while beginning a transaction.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BeginError {
/// Exactly one transaction is already open.
TransactionActive,
}
/// Failure while writing a scalar into the bounded transaction.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum SetError {
/// No transaction is open.
NoTransaction,
/// The candidate rejected the local scalar without mutation.
Field(FieldWriteError),
}
/// Failure while validating or committing a transaction.
#[derive(Debug, PartialEq, KaptanError)]
pub enum CommitError<E> {
/// No transaction is open.
NoTransaction,
/// The complete candidate violates a relational or capability invariant.
InvalidCandidate(ConfigViolation),
/// The storage backend could not commit the snapshot.
Store(E),
}
/// Owns the immutable boot-active profile, the latest durable candidate, and
/// exactly one RAM edit transaction.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
/// changes after boot. `commit` atomically persists a complete validated
/// candidate, then reports [`ConfigState::RestartRequired`].
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
committed: C,
transaction: Option<C>,
context: C::Context,
active_generation: Option<Generation>,
committed_generation: Option<Generation>,
store: S,
restart_required: bool,
}
impl<C, S> ConfigManager<C, S>
where
C: ConfigModel,
S: ProfileStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
/// is sufficient to arm; no recovery is implicit.
pub fn new(factory: C, context: C::Context, store: S) -> Result<Self, ConfigViolation> {
let active = factory.validate(&context)?;
Ok(Self {
active,
committed: factory,
transaction: None,
context,
active_generation: None,
committed_generation: None,
store,
restart_required: false,
})
}
/// Recovers the newest valid committed snapshot or uses the supplied
/// factory candidate when the store is empty.
pub fn recover(
factory: C,
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
committed,
transaction: None,
context,
active_generation: Some(generation),
committed_generation: Some(generation),
store,
restart_required: false,
})
}
/// Returns the immutable configuration selected at boot.
pub const fn active(&self) -> &C::Active {
&self.active
}
/// Returns the last durably committed candidate value.
pub fn committed_value(&self, id: FieldId) -> Option<ConfigValue> {
self.committed.field_value(id)
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
}
/// Returns a transaction value when an edit is open.
pub fn transaction_value(&self, id: FieldId) -> Option<ConfigValue> {
self.transaction.as_ref()?.field_value(id)
}
/// Returns descriptor metadata by stable schema index.
pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> {
C::descriptor(index)
}
/// Number of fields exposed by the compiled registry.
pub fn field_count(&self) -> u16 {
C::field_count()
}
/// Returns the current lifecycle state.
pub const fn state(&self) -> ConfigState {
if self.transaction.is_some() {
ConfigState::Editing
} else if self.restart_required {
ConfigState::RestartRequired
} else {
ConfigState::Active
}
}
/// Returns the boot-active snapshot generation.
pub const fn active_generation(&self) -> Option<Generation> {
self.active_generation
}
/// Returns the most recently recovered or committed generation.
pub const fn committed_generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Returns the most recently recovered or committed generation.
///
/// Retained as a concise alias for optimistic commit callers.
pub const fn generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
return Err(BeginError::TransactionActive);
}
self.transaction = Some(self.committed.clone());
Ok(())
}
/// Applies one locally valid scalar in the open transaction.
///
/// Cross-field-invalid intermediate states are allowed so multi-field
/// edits can be made naturally. They are rejected by `validate`/`commit`.
pub fn set(&mut self, id: FieldId, value: ConfigValue) -> Result<ConfigValue, SetError> {
let candidate = self.transaction.as_mut().ok_or(SetError::NoTransaction)?;
candidate.set_field(id, value).map_err(SetError::Field)?;
candidate
.field_value(id)
.ok_or(SetError::Field(FieldWriteError::UnknownField(id)))
}
/// Validates the complete open transaction without changing persistent or
/// active state.
pub fn validate(&self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
candidate
.validate(&self.context)
.map(|_| ())
.map_err(CommitError::InvalidCandidate)
}
/// Replaces the open RAM transaction with one complete imported candidate.
///
/// Unlike scalar edits, imports are accepted only after complete
/// relational and capability validation. Neither the boot-active nor the
/// committed snapshot is changed here.
pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError<S::Error>> {
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let transaction = self
.transaction
.as_mut()
.ok_or(CommitError::NoTransaction)?;
*transaction = candidate;
Ok(())
}
/// Validates and durably commits the complete open transaction.
pub fn commit(&mut self) -> Result<Generation, CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let generation = self.store.persist(candidate).map_err(CommitError::Store)?;
self.committed = candidate.clone();
self.transaction = None;
self.committed_generation = Some(generation);
self.restart_required = true;
Ok(generation)
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
self.transaction.take().is_some()
}
/// Releases the owned store when the configuration owner shuts down.
pub fn into_store(self) -> S {
self.store
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
const DESCRIPTORS: [FieldDescriptor; 2] = [
FieldDescriptor {
id: VALUE,
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(2),
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
FieldDescriptor {
id: OTHER,
display_key: "test.other",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(4),
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
];
#[derive(Clone)]
struct Candidate {
value: u32,
other: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Active {
value: u32,
other: u32,
}
impl ConfigModel for Candidate {
type Active = Active;
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"CONFIGLIFECYCLE1";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
DESCRIPTORS.get(usize::from(index))
}
fn field_count() -> u16 {
DESCRIPTORS.len() as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(self.value)),
OTHER => Some(ConfigValue::U32(self.other)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(active.value)),
OTHER => Some(ConfigValue::U32(active.other)),
_ => None,
}
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
actual: value.kind(),
});
};
if !(1..=10).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
match id {
VALUE => self.value = value,
OTHER => self.other = value,
_ => return Err(FieldWriteError::UnknownField(id)),
}
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
if self.value > self.other {
return Err(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent,
));
}
Ok(Active {
value: self.value,
other: self.other,
})
}
}
struct MemoryStore;
impl ProfileStore<Candidate> for MemoryStore {
type Error = ();
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
Ok(Generation::new(7))
}
}
#[test]
fn invalid_intermediate_transaction_never_changes_active_or_committed() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
assert_eq!(
manager.set(VALUE, ConfigValue::U32(5)),
Ok(ConfigValue::U32(5))
);
assert_eq!(
manager.validate(),
Err(CommitError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent
)))
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(2)));
assert_eq!(manager.transaction_value(VALUE), Some(ConfigValue::U32(5)));
assert_eq!(manager.state(), ConfigState::Editing);
}
#[test]
fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(Generation::new(7)));
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.active_generation(), None);
assert_eq!(manager.committed_generation(), Some(Generation::new(7)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
assert!(manager.abort());
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
}
}
@@ -0,0 +1,48 @@
//! Persistence interface for complete validated candidate snapshots.
use core::num::NonZeroU64;
/// Storage-owned monotonically increasing committed snapshot generation.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Generation(NonZeroU64);
impl Generation {
/// Creates a generation supplied only by a persistence backend.
pub const fn new(value: u64) -> Self {
match NonZeroU64::new(value) {
Some(value) => Self(value),
None => panic!("configuration generation must be nonzero"),
}
}
/// Parses an on-disk or wire generation, rejecting the factory sentinel.
pub const fn try_new(value: u64) -> Option<Self> {
match NonZeroU64::new(value) {
Some(value) => Some(Self(value)),
None => None,
}
}
/// Returns the committed generation value.
pub const fn get(self) -> u64 {
self.0.get()
}
}
/// Durable storage of complete configuration snapshots.
///
/// Flight implementations use a two-slot journal: a record contains a header,
/// bounded CBOR payload, integrity check, and a commit marker written last.
/// A backend must retain its previous valid record until the replacement is
/// fully verified. Host file stores may implement the same interface without
/// claiming flash power-loss semantics.
pub trait ProfileStore<C> {
/// Backend-specific I/O or integrity failure.
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<(Generation, C)>, Self::Error>;
/// Persists one complete already-validated candidate atomically.
fn persist(&mut self, candidate: &C) -> Result<Generation, Self::Error>;
}
@@ -0,0 +1,63 @@
//! Local scalar configuration sections implemented by the derive macro.
use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError};
/// Validates local scalar invariants of one record.
///
/// The derive macro handles finite, nonzero, and inclusive range checks.
/// Cross-field, capability, calibration, and lifecycle invariants belong in a
/// handwritten post-validation hook on the owning vehicle schema.
pub trait ValidateFields {
/// Rejects the first violated scalar or declared post-validation rule.
fn validate_fields(&self) -> Result<(), ConfigViolation>;
}
/// A complete scalar configuration section generated from `#[config(...)]`
/// field attributes.
///
/// Its metadata, default construction, reads, local writes, and scalar
/// validation are all produced from the same field declarations. A section
/// setter never commits a locally invalid value. Cross-section and capability
/// invariants deliberately remain the aggregate candidate's responsibility.
pub trait ConfigSection: ValidateFields {
/// Number of scalar descriptors generated for this section.
const FIELD_COUNT: usize;
/// Builds the section from derive-generated defaults after validating it.
fn default_section() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Returns one scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Applies one scalar only when this section owns the ID and its local
/// generated validation accepts the new value.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Validates only generated scalar invariants. This intentionally omits
/// a derive `post` hook so staged multi-field edits can remain temporarily
/// cross-field-invalid until aggregate validation/commit.
fn validate_local_fields(&self) -> Result<(), ConfigViolation>;
}
/// An ordered aggregate of generated configuration sections.
///
/// The derive owns routing/default construction so adding a field to an
/// existing section cannot silently bypass local validation. The aggregate
/// owner still defines capability checks and immutable active construction.
pub trait ConfigAggregate: ValidateFields {
/// Builds and fully validates all nested sections from their derived
/// defaults.
fn default_aggregate() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Returns one nested scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Routes one scalar write to its owning generated section. The setter
/// applies local validation only, so a transaction may temporarily hold a
/// cross-field-invalid multi-edit candidate.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
}