ChildSpec and ChildDeclaration
Language: δΈζ
How do ChildSpec and ChildDeclaration relate?
ChildDeclaration is the external declaration that arrives from configuration and RPC. ChildSpec is the internal specification the supervisor runtime uses to register, start, and restart children. The two share many fields but serve different roles. They are connected through TryFrom conversion, which also fills in defaults.
What each one is
ChildDeclaration | ChildSpec | |
|---|---|---|
| Module | src/spec/child_declaration.rs | src/spec/child.rs |
| Role | Input model for YAML, add_child payloads, and similar sources | Runtime model in the registry and control loop |
| Typical source | Config file deserialization, dynamic child add requests | Converted from a declaration, or built directly in code |
| Can it run alone? | No. It has no factory and no fully materialized policy objects | Yes. The supervisor manages lifecycle from it |
ChildDeclaration focuses on a serializable, validatable declaration: names, dependency names, environment variables, secret placeholders, health_check / readiness config blocks, and rules such as validate_child_declaration (name format, ${SECRET} syntax, and so on).
Beyond declaration fields, ChildSpec also carries runtime essentials such as:
- A resolved
ChildIdderived fromname factory: Option<Arc<dyn TaskFactory>>, the task factory that actually runs work (not part of serde)- Materialized
HealthPolicy,ReadinessPolicy,ShutdownPolicy, andBackoffPolicy - Runtime fields such as
isolationandcleanup_paths
How they connect
The data flow looks like this:
YAML / add_child RPC
|
v
ChildDeclaration ---- validate_child_declaration ----+
| |
| TryFrom<ChildDeclaration> for ChildSpec |
v |
ChildSpec --------------------------------------------+
|
v
Register topology, start children, policy pipeline, restart / meltdown, etc.
The conversion lives in TryFrom<ChildDeclaration> for ChildSpec inside child_declaration.rs. It performs steps such as:
name->ChildId::new(&decl.name)- dependency names in
dependencies->Vec<ChildId> health_check->HealthPolicywith default intervalsreadinesspresent ->ReadinessPolicy::Explicit, otherwiseImmediateshutdown_policy/backoff_policyand similar fields receive defaults during conversion even when the declaration omits them
When a child is added dynamically, PendingChild keeps both the declaration and the converted child_spec. Auditing also stores a SHA-256 of the declaration (declaration_hash) for reconciliation and compensation.
Shared types
Shared enums and config structs such as RestartPolicy, TaskKind, and HealthCheckConfig are defined in child.rs. ChildDeclaration reuses them to avoid parallel type trees. The top-level containers remain separate: declaration container vs specification container.
ChildSpec construction paths
The repository has 6 paths that construct ChildSpec. They serve different use cases and should not be collapsed into one entry point.
| Path | Typical entry | Use case | Validation behavior |
|---|---|---|---|
| Builder | ChildSpecBuilder::worker, service, job, sidecar, supervisor, new | Direct runtime spec construction in Rust code | build() calls ChildSpec::validate() |
| Worker convenience function | ChildSpec::worker(...) | Worker default bundle only | Delegates to ChildSpecBuilder::worker(...).build() |
| Declaration conversion | TryFrom<ChildDeclaration> for ChildSpec | YAML config, RPC payloads, dynamic child adds | validate_child_declaration runs before conversion, and supervisor-level validation catches final issues |
| Role template | ServiceTemplate::child_spec, JobTemplate::child_spec, and related role templates | Caller already implemented role traits but does not want to hand-build adapters and specs | Calls the matching ChildSpecBuilder internally |
| Macro-generated helper | child_spec() generated by #[service], #[worker], #[job], #[sidecar], and #[supervisor_role] | Default role contract entry path | Generated code calls the matching ChildSpecBuilder |
| Serde | serde_json::from_value::<ChildSpec>(...) | Mainly tests for deserialization defaults and invalid enum handling | Does not pass through the builder, so callers must validate before runtime use or rely on later spec validation |
Important boundaries:
ChildSpecBuilder::build()is the main exit for Rust code construction paths.- Configuration and RPC should not accept
ChildSpecdirectly. They should acceptChildDeclarationfirst, then convert it intoChildSpec. - Role templates and macros are not new runtime models. They turn role lifecycle objects into adapters, then call
ChildSpecBuilderto produce specs. - Serde can construct
ChildSpecbecauseChildSpecderivesDeserialize. That path does not automatically callChildSpecBuilder::build().
Adjacent paths that do not construct a ChildSpec:
| Entry | Why it is not a ChildSpec construction path |
|---|---|
SupervisorSpec::root(Vec<ChildSpec>) | It accepts already constructed child specs and builds a supervisor spec |
SupervisorSpecBuilder::root(Vec<ChildSpec>) | It wraps supervisor spec construction and does not create an individual child spec |
ConfigState::to_supervisor_spec() | It assembles a supervisor spec from the Vec<ChildSpec> already stored in ConfigState |
bind_child_factory(...) | It binds a task factory to an existing ChildSpec and does not create a new one |
clone() | It copies an existing ChildSpec instead of generating one from an input model |
How to remember them
- Writing config, handling API input, validating declarations -> think
ChildDeclaration - Seeing how the supervisor manages a child or what the policy engine reads -> think
ChildSpec - Asking whether YAML and runtime use the same thing -> same underlying information, different lifecycle stage: declaration is input, spec is the landed form
In-code construction
Configuration and RPC should still use ChildDeclaration. When you construct a runtime spec directly in Rust, prefer ChildSpecBuilder:
#![allow(unused)]
fn main() {
use rust_supervisor::id::types::ChildId;
use rust_supervisor::policy::task_role_defaults::TaskRole;
use rust_supervisor::spec::child::TaskKind;
use rust_supervisor::spec::child_builder::ChildSpecBuilder;
use rust_supervisor::task::factory::{TaskResult, service_fn};
use std::sync::Arc;
let factory = service_fn(|_ctx| async { TaskResult::Succeeded });
let spec = ChildSpecBuilder::worker(
ChildId::new("worker"),
"worker",
TaskKind::AsyncWorker,
Arc::new(factory),
)
.task_role(TaskRole::Worker)
.tag("invoice")
.build()?;
}
Entry methods:
| Method | Purpose |
|---|---|
ChildSpecBuilder::worker(...) | Async or blocking worker; defaults match ChildSpec::worker |
ChildSpecBuilder::service(...) | Long-running service; sets TaskRole::Service |
ChildSpecBuilder::job(...) | Finite job; sets TaskRole::Job |
ChildSpecBuilder::sidecar(...) | Sidecar; sets sidecar binding and the primary child dependency |
ChildSpecBuilder::supervisor(...) | Nested supervisor; no factory |
ChildSpecBuilder::new(...) | Minimal skeleton; caller must set kind and, for workers, factory |
Build exit:
| Method | Behavior |
|---|---|
build() | Calls ChildSpec::validate() after construction; returns SupervisorError on failure |
ChildSpec::worker(...) remains available. It delegates to ChildSpecBuilder::worker(...).build() and also returns Result<ChildSpec, SupervisorError>.
For field-by-field mapping and defaults through TryFrom, see child-spec-builder.md for builder details, or inspect child_declaration.rs directly.