Skip to content

symfonic.agent.configuration.validation

validation

Pure, deterministic validation across normalized configuration families.

validate_child_config

validate_child_config(parent: NormalizedConfig, child: NormalizedConfig, *, parent_context: ValidationContext | None = None, child_context: ValidationContext | None = None) -> None

Enforce XV-12 native child narrowing.

Source code in src/symfonic/agent/configuration/validation.py
def validate_child_config(
    parent: NormalizedConfig,
    child: NormalizedConfig,
    *,
    parent_context: ValidationContext | None = None,
    child_context: ValidationContext | None = None,
) -> None:
    """Enforce XV-12 native child narrowing."""
    violations: list[str] = []
    added = set(child.capabilities) - set(parent.capabilities)
    if added:
        violations.append(f"added capabilities {sorted(added)}")
    if child.runtime.limits.recursion_depth > parent.runtime.limits.recursion_depth:
        violations.append("raised recursion_depth")
    if child.runtime.limits.max_agent_depth > parent.runtime.limits.max_agent_depth:
        violations.append("raised max_agent_depth")
    parent_effects = (parent_context or ValidationContext()).explicit_effects
    child_effects = (child_context or ValidationContext()).explicit_effects
    if not child_effects.issubset(parent_effects):
        violations.append("widened explicit effects")
    parent_prompt = _family(parent, "cap.prompt", PromptContextConfig)
    child_prompt = _family(child, "cap.prompt", PromptContextConfig)
    parent_self_edit = bool(
        parent_prompt and parent_prompt.blocks and parent_prompt.blocks.self_edit
    )
    child_self_edit = bool(child_prompt and child_prompt.blocks and child_prompt.blocks.self_edit)
    if child_self_edit and not parent_self_edit:
        violations.append("acquired prompt-block self-edit")
    if violations:
        raise ConfigurationError(
            "XV-12 child configuration widens parent: " + "; ".join(violations)
        )

validate_dependencies

validate_dependencies(config: NormalizedConfig, *, bindings: ConfigBindings, context: ValidationContext) -> None

Collect all native-surface violations in rule-id order, without effects.

Source code in src/symfonic/agent/configuration/validation.py
def validate_dependencies(
    config: NormalizedConfig,
    *,
    bindings: ConfigBindings,
    context: ValidationContext,
) -> None:
    """Collect all native-surface violations in rule-id order, without effects."""
    prompt = _family(config, "cap.prompt", PromptContextConfig)
    memory = _family(config, "cap.memory", MemoryConfig)
    tools = _family(config, "cap.tools", ToolingConfig)
    human = _family(config, "cap.human", HumanInteractionConfig)
    safety = _family(config, "cap.safety", SafetyConfig)
    consolidation = _family(config, "svc.consolidation", ConsolidationServiceConfig)
    checkpoint = _family(config, "svc.checkpoint", CheckpointServiceConfig)
    session = _family(config, "svc.session", SessionServiceConfig)
    errors: list[tuple[str, str]] = []
    advisories: list[tuple[str, str]] = []

    # XV-05
    if prompt:
        if prompt.layering == "stratigraphic" and prompt.hms_pipeline is None:
            errors.append(("XV-05", "stratigraphic layering requires hms_pipeline"))
        if prompt.caching.manifest_position == "volatile" and (
            prompt.layering != "stratigraphic" or prompt.strategy == "jit"
        ):
            errors.append(("XV-05", "volatile manifest requires stratigraphic non-JIT"))
        if prompt.jit and prompt.jit.manifest_token_budget > 0 and prompt.strategy != "jit":
            errors.append(("XV-05", "jit manifest_token_budget requires JIT strategy"))
    if (
        tools
        and tools.intent_routing
        and not tools.intent_routing.always_include
        and (not prompt or prompt.strategy != "jit")
    ):
        errors.append(("XV-05", "empty always_include requires JIT strategy"))

    # XV-06 / XV-07
    spreading = bool(memory and memory.hydration and memory.hydration.spreading)
    if prompt and prompt.activation_log != "never" and not spreading:
        errors.append(("XV-06", "activation_log requires hydration.spreading"))
    cadence = (
        prompt.hms_pipeline.extraction_directive_cadence if prompt and prompt.hms_pipeline else 0
    )
    if cadence > 0 and (memory is None or memory.extraction is None):
        errors.append(("XV-07", "extraction directive requires cap.memory.extraction"))

    # XV-08
    if (
        prompt
        and "haiku" in config.runtime.model.model_name.lower()
        and "1h" in {prompt.caching.system_prefix_ttl, prompt.caching.tools_ttl}
    ):
        advisories.append(("XV-08", "1h cache tier may be ignored on Haiku"))

    # XV-10 / XV-11
    if (
        safety
        and safety.precondition_gate is not None
        and (memory is None or MemoryLayer.PROCEDURAL not in memory.layers)
    ):
        errors.append(("XV-10", "precondition_gate requires procedural memory"))
    if (
        prompt
        and prompt.procedural_preflight is not None
        and (memory is None or MemoryLayer.PROCEDURAL not in memory.layers)
    ):
        errors.append(("XV-10", "procedural_preflight requires procedural memory"))
    if safety and safety.fabrication.mode == "revise" and safety.metacognition is None:
        errors.append(("XV-11", "fabrication revise requires metacognition"))

    # XV-13 / XV-14
    intent_consumers = bool(
        tools and tools.intent_routing and tools.intent_routing.mode != "off"
    ) + bool(
        memory
        and memory.hydration
        and memory.hydration.intent_filter
        and memory.hydration.intent_filter.mode != "off"
    )
    if intent_consumers == 2 and bindings.intent_classifier is None:
        errors.append(("XV-13", "intent consumers require one classifier binding"))
    callable_bindings = {
        "scheduler": bindings.scheduler,
        "metacognition_gate": bindings.metacognition_gate,
        "role_model_resolver": bindings.role_model_resolver,
        "hms_budget_callback": bindings.hms_budget_callback,
    }
    for name, binding in callable_bindings.items():
        if binding is not None and not callable(binding) and name != "scheduler":
            errors.append(("XV-14", f"{name} binding does not satisfy its protocol"))
    selective = bool(safety and safety.metacognition and safety.metacognition.selective_gate)
    if bindings.metacognition_gate is not None and not selective:
        errors.append(("XV-14", "metacognition_gate has no selective_gate config"))

    # XV-15 / XV-16 / XV-17
    telemetry = config.integrations.telemetry
    if telemetry and "opentelemetry" not in context.available_extras:
        errors.append(("XV-15", "telemetry selection requires opentelemetry extra"))
    if consolidation and consolidation.entity_linking:
        extractor = consolidation.entity_linking.extractor
        if extractor == "spacy" and "spacy" not in context.available_extras:
            errors.append(("XV-15", "spacy entity linker requires spacy extra"))
    paths = []
    if memory and memory.extraction and memory.extraction.template_path:
        paths.append(memory.extraction.template_path)
    if memory and memory.telemetry and memory.telemetry.sink != "logger":
        paths.append(memory.telemetry.sink)
    if checkpoint and checkpoint.sqlite_path:
        paths.append(checkpoint.sqlite_path)
    if paths and "filesystem" not in context.explicit_effects:
        errors.append(("XV-16", "path-bearing configuration requires filesystem effect"))
    if memory and memory.scope_blend.mode != "off" and not context.scope_has_parent:
        errors.append(("XV-17", "scope blending requires a parent scope"))

    # XV-18 / XV-19 / XV-21
    if (
        human
        and (human.elicitation or human.interrupts)
        and (checkpoint is None or "svc.background" not in config.services)
    ):
        errors.append(("XV-18", "human pause/resume requires checkpoint and background"))
    if session and session.transcript and checkpoint is None:
        errors.append(("XV-18", "transcript persistence requires checkpoint"))
    if session and session.transcript and checkpoint and checkpoint.backend == "memory":
        advisories.append(("XV-18", "persisted transcripts use an ephemeral checkpoint"))
    if consolidation and consolidation.nightly_nap and bindings.scheduler is None:
        advisories.append(("XV-19", "nightly_nap has no scheduler binding"))
    if consolidation and memory is None:
        errors.append(("XV-21", "consolidation requires cap.memory"))

    if errors:
        ordered = sorted(errors, key=lambda item: (item[0], item[1]))
        detail = "\n".join(f"{rule}: {message}" for rule, message in ordered)
        raise ConfigurationError(f"Configuration validation failed:\n{detail}")
    for rule, message in sorted(advisories):
        warnings.warn(f"{rule}: {message}", UserWarning, stacklevel=3)