Skip to content

symfonic.core.prompt.blocks.validation

validation

Construction-time validation of the declared prompt blocks.

A misconfigured block does not fail at the point it is misconfigured. It fails months later, quietly, as a per-tenant BOUNDARIES block that has been serving one global value to every tenant on the instance -- or as an operator "edit" API against a source that cannot show what the block used to say, so the edit has no undo. Neither of those raises anything at render time; both look exactly like a working configuration.

So the rules live here, and they run at construction: when a :class:~symfonic.core.prompt.blocks.spec.PromptBlockSpec is built, and again over the whole declared set when the framework config is built. A rule that only fires on the request path is a rule something can be argued into skipping; a rule that fires at construction means the process does not start.

Two errors and one warning

Error -- scope pairing (§5.7, §5.8 pt2). scope is declared on the block; scope_aware is a property of the source. Anything other than scope='deployment' against a scope-unaware source is rejected: the operator asked for per-tenant content from an adapter that serves one value for the whole install, and nothing downstream can detect the difference. A deployment-scoped block on that same source is a legitimate configuration and stays silent -- this replaced an earlier heuristic warning that fired on correct configurations.

Error -- operator_editable pairing (§5.9, §5.11, D14). operator_editable=True means core exposes a write API for the block, so the source must be able to append a revision and show its history. The check is :func:isinstance against :class:~symfonic.core.prompt.blocks.protocol.WritableBlockSource -- structural, not a self-reported flag. The reverse pairing is not an error: declaring a writable source read-only is a legitimate, and safer, configuration.

Warning -- offline safety (§5.2). A platform- or operating-tier block backed by a non-offline-safe adapter is legal and sometimes right, so there is no correct pairing to enforce. But D3's narrowing -- giving up offline USER_PROFILE -- was accepted because the authored spine would still render during a datastore outage, and pointing IDENTITY at a database silently retracts the premise that decision was taken on. Warning is the right instrument: it converts a silent property loss into a loud one without rejecting a configuration the adopter may genuinely want.

The warning is emitted once per process per offending (block, source type) pair, following the idiom at core/providers.py -- a long-running agent that rebuilds its config per tenant must not reprint the same line a thousand times. The pair is in the memo key rather than a single process-wide flag because the message names the block: two differently-misconfigured blocks are two different facts the adopter needs, and collapsing them would report the first and hide the second.

What isinstance catches here is member presence only -- see :mod:symfonic.core.prompt.blocks.protocol. A source that never implemented append_revision is caught; one that implements it badly is not.

WRITE_CAPABILITIES module-attribute

WRITE_CAPABILITIES: tuple[str, ...] = (
    "list_revisions",
    "load_revision",
    "append_revision",
)

The write-specific members WritableBlockSource adds on top of BlockSource -- the two history methods plus the append verb.

Named here so the rejection message can say which capability is missing rather than only that the Protocol was not satisfied. This is not the full member set isinstance(source, WritableBlockSource) requires: BlockSource's own members (load, offline_safe, scope_aware) are required too, and are checked separately by :func:missing_write_capabilities -- see :data:_BASE_SOURCE_MEMBERS.

AuthoredSpineOfflineWarning

Bases: UserWarning

An authored-tier block is backed by a source that needs the network.

Emitted at construction for a platform- or operating-tier block whose source reports offline_safe=False. The configuration is valid and is not rejected; what the adopter loses is the guarantee that BOUNDARIES, IDENTITY and RULES still render when the backing datastore is unreachable.

Adopters who have weighed that and accept it silence the category with warnings.filterwarnings("ignore", category=AuthoredSpineOfflineWarning).

check_operator_editable

check_operator_editable(
    *, block_name: str, source: Any, operator_editable: bool
) -> None

Reject operator_editable=True against a non-writable source.

operator_editable=False is never rejected, on any source: serving a writable source read-only is a deliberate, and strictly safer, configuration.

Source code in src/symfonic/core/prompt/blocks/validation.py
def check_operator_editable(*, block_name: str, source: Any, operator_editable: bool) -> None:
    """Reject ``operator_editable=True`` against a non-writable source.

    ``operator_editable=False`` is never rejected, on any source:
    serving a writable source read-only is a deliberate, and strictly
    safer, configuration.
    """
    if not operator_editable:
        return
    if isinstance(source, str) and source == MEMORY_SOURCE:
        raise ValueError(
            f"block {block_name!r} declares operator_editable=True on the "
            "memory lane. The operator write API appends revisions through a "
            "WritableBlockSource; the memory lane has no such source."
        )
    # The gate is missing_write_capabilities() being empty, not
    # isinstance(source, WritableBlockSource): the latter is a
    # runtime_checkable Protocol check, and Python's typing machinery
    # verifies member *presence* for a method-shaped member, never that
    # it is actually callable. A source with ``load = True`` (a bool, not
    # a method) satisfies isinstance() while every call to it raises
    # TypeError. missing_write_capabilities() checks the method members
    # by callable() specifically to close that gap, so it is used as the
    # sole gate here -- an empty list is strictly at least as strong a
    # guarantee as isinstance() returning True, and catches what
    # isinstance() alone does not.
    missing = sorted(missing_write_capabilities(source))
    if not missing:
        return
    raise ValueError(
        f"block {block_name!r} declares operator_editable=True but its source "
        f"{_source_label(source)} is not a WritableBlockSource: missing "
        f"{missing!r}. operator_editable means core exposes a write API for "
        "this block, so the source must be able to append a revision AND show "
        "what it used to hold -- an edit with no history and no restore path "
        "is not an edit anyone can undo. Declare operator_editable=False to "
        "serve it read-only."
    )

check_scope_pairing

check_scope_pairing(
    *, block_name: str, source: Any, scope: str
) -> None

Reject a non-deployment scope against a scope-unaware source.

A deployment-scoped block is silent whatever the source does: one value for the whole install is exactly what a scope-unaware adapter provides, and that is a legitimate configuration rather than a degraded one.

Source code in src/symfonic/core/prompt/blocks/validation.py
def check_scope_pairing(*, block_name: str, source: Any, scope: str) -> None:
    """Reject a non-deployment scope against a scope-unaware source.

    A ``deployment``-scoped block is silent whatever the source does:
    one value for the whole install is exactly what a scope-unaware
    adapter provides, and that is a legitimate configuration rather than
    a degraded one.
    """
    if scope == "deployment" or is_scope_aware(source):
        return
    raise ValueError(
        f"block {block_name!r} declares scope={scope!r} but its source "
        f"{_source_label(source)} is not scope_aware: it serves one value for "
        "every tenant on this deployment. The block would look per-tenant in "
        "config while silently returning the same global content to all of "
        "them, and nothing downstream can tell the difference. Declare "
        "scope='deployment' to accept a deployment-global block, or supply a "
        "source that keys on scope.scope_path."
    )

is_offline_safe

is_offline_safe(source: Any) -> bool

Return whether source can still be read during an outage.

The memory lane is not offline-safe: it reads the datastore, and that is precisely the dependency §2.7 says the authored spine must not have. An adapter that declares nothing is treated as unsafe, so the quieter outcome is never the accidental one.

Raises:

Type Description
TypeError

source.offline_safe is present and not a bool.

Source code in src/symfonic/core/prompt/blocks/validation.py
def is_offline_safe(source: Any) -> bool:
    """Return whether ``source`` can still be read during an outage.

    The memory lane is **not** offline-safe: it reads the datastore, and
    that is precisely the dependency §2.7 says the authored spine must
    not have. An adapter that declares nothing is treated as unsafe, so
    the quieter outcome is never the accidental one.

    Raises:
        TypeError: ``source.offline_safe`` is present and not a ``bool``.
    """
    if isinstance(source, str):
        return False
    return _coerced_flag(source, "offline_safe")

is_scope_aware

is_scope_aware(source: Any) -> bool

Return whether source can serve different content per scope.

The memory lane is scope-aware by construction -- it reads the tenant's own graph, so it cannot serve another tenant's content. Any other source is asked for its declared scope_aware member, and a source that does not declare one is treated as not scope-aware: an undeclared isolation property is not an isolation guarantee.

Raises:

Type Description
TypeError

source.scope_aware is present and not a bool.

Source code in src/symfonic/core/prompt/blocks/validation.py
def is_scope_aware(source: Any) -> bool:
    """Return whether ``source`` can serve different content per scope.

    The memory lane is scope-aware by construction -- it reads the
    tenant's own graph, so it cannot serve another tenant's content. Any
    other source is asked for its declared ``scope_aware`` member, and a
    source that does not declare one is treated as **not** scope-aware:
    an undeclared isolation property is not an isolation guarantee.

    Raises:
        TypeError: ``source.scope_aware`` is present and not a ``bool``.
    """
    if isinstance(source, str) and source == MEMORY_SOURCE:
        return True
    return _coerced_flag(source, "scope_aware")

missing_write_capabilities

missing_write_capabilities(source: Any) -> list[str]

Return the WritableBlockSource members source does not present.

Empty only for a source that satisfies WritableBlockSource. Covers the full member set, split by how "present" is checked:

  • :data:WRITE_CAPABILITIES and :data:_BASE_CALLABLE_MEMBERS (load) are methods, checked by callable() -- a source that sets one of these to a non-callable value (e.g. load = True) is reported as missing it, even though a bare isinstance(source, WritableBlockSource) would not catch that: a runtime_checkable Protocol's isinstance verifies member presence, not callability, for method-shaped members.
  • :data:_BASE_FLAG_MEMBERS (offline_safe, scope_aware) are data members, checked by hasattr().

Used to name the gap in the rejection message: "not writable" sends the adopter reading Protocol source, "missing append_revision" sends them to the one method they have to add -- and a source presenting all three write verbs but missing a capability flag now names that gap instead of reporting none.

Source code in src/symfonic/core/prompt/blocks/validation.py
def missing_write_capabilities(source: Any) -> list[str]:
    """Return the ``WritableBlockSource`` members ``source`` does not present.

    Empty **only** for a source that satisfies ``WritableBlockSource``.
    Covers the full member set, split by how "present" is checked:

    * :data:`WRITE_CAPABILITIES` and :data:`_BASE_CALLABLE_MEMBERS`
      (``load``) are methods, checked by ``callable()`` -- a source that
      sets one of these to a non-callable value (e.g. ``load = True``)
      is reported as missing it, even though a bare
      ``isinstance(source, WritableBlockSource)`` would not catch that:
      a ``runtime_checkable`` Protocol's ``isinstance`` verifies member
      *presence*, not callability, for method-shaped members.
    * :data:`_BASE_FLAG_MEMBERS` (``offline_safe``, ``scope_aware``) are
      data members, checked by ``hasattr()``.

    Used to name the gap in the rejection message: "not writable" sends
    the adopter reading Protocol source, "missing ``append_revision``"
    sends them to the one method they have to add -- and a source
    presenting all three write verbs but missing a capability flag now
    names *that* gap instead of reporting none.
    """
    if isinstance(source, str):
        return [*_BASE_SOURCE_MEMBERS, *WRITE_CAPABILITIES]
    callable_members = (*WRITE_CAPABILITIES, *_BASE_CALLABLE_MEMBERS)
    missing = [verb for verb in callable_members if not callable(getattr(source, verb, None))]
    missing += [member for member in _BASE_FLAG_MEMBERS if not hasattr(source, member)]
    return missing

validate_block_specs

validate_block_specs(
    specs: Iterable[PromptBlockSpec],
) -> None

Validate every declared block against the source that serves it.

The config-level entry point: each spec already checked itself at its own construction, and this re-checks the set as a whole so a spec built by any other route (deserialisation, model_construct) is caught before the framework starts.

Zero declared blocks performs no validation and emits nothing -- the feature is off, and an off feature must be indistinguishable from a release that never had it.

Source code in src/symfonic/core/prompt/blocks/validation.py
def validate_block_specs(specs: Iterable[PromptBlockSpec]) -> None:
    """Validate every declared block against the source that serves it.

    The config-level entry point: each spec already checked itself at its
    own construction, and this re-checks the set as a whole so a spec
    built by any other route (deserialisation, ``model_construct``) is
    caught before the framework starts.

    Zero declared blocks performs no validation and emits nothing --
    the feature is off, and an off feature must be indistinguishable from
    a release that never had it.
    """
    for spec in specs:
        check_scope_pairing(block_name=spec.name, source=spec.source, scope=spec.scope)
        check_operator_editable(
            block_name=spec.name,
            source=spec.source,
            operator_editable=spec.operator_editable,
        )
        warn_if_offline_unsafe(block_name=spec.name, source=spec.source, tier=spec.tier)

warn_if_offline_unsafe

warn_if_offline_unsafe(
    *, block_name: str, source: Any, tier: str
) -> None

Warn once per process when an authored block needs the network.

No-op for the learned tiers: losing USER_PROFILE during an outage costs personalisation for a turn, which is the trade D3 already accepted. It is the authored spine -- BOUNDARIES, IDENTITY, RULES -- whose survival that decision was taken on.

Source code in src/symfonic/core/prompt/blocks/validation.py
def warn_if_offline_unsafe(*, block_name: str, source: Any, tier: str) -> None:
    """Warn once per process when an authored block needs the network.

    No-op for the learned tiers: losing ``USER_PROFILE`` during an outage
    costs personalisation for a turn, which is the trade D3 already
    accepted. It is the authored spine -- BOUNDARIES, IDENTITY, RULES --
    whose survival that decision was taken on.
    """
    if tier not in AUTHORED_TIERS or is_offline_safe(source):
        return
    key = (block_name, _source_label(source))

    # Check-then-reserve under a lock, so two threads racing the same
    # pair cannot both pass the check: the second reader either sees the
    # first thread's reservation and returns, or -- if the first thread's
    # warn() below raises and rolls the reservation back -- reacquires
    # the lock and warns itself. Either way, exactly one warning is
    # delivered per successfully-completed call, never zero and never an
    # unbounded number.
    with _OFFLINE_UNSAFE_WARNED_LOCK:
        if key in _OFFLINE_UNSAFE_WARNED:
            return
        _OFFLINE_UNSAFE_WARNED.add(key)

    try:
        warnings.warn(
            f"{tier}-tier block {block_name!r} is backed by {_source_label(source)}, "
            "which reports offline_safe=False. This configuration is valid, but the "
            "authored spine no longer survives a datastore or network outage: when "
            f"that source is unreachable, {block_name!r} cannot be read, so the "
            "agent's authored context is missing exactly when its failure policy "
            "has to decide the turn. If offline survival was assumed, back this "
            "block with an offline-safe source (a file or a static literal).",
            category=AuthoredSpineOfflineWarning,
            stacklevel=3,
        )
    except BaseException:
        # The warning did not complete (most commonly an escalated
        # ``warnings.filterwarnings("error", ...)`` turning this into a
        # raised exception). Roll the reservation back so the NEXT
        # rebuild of this same (block, source) pair warns -- or raises --
        # again, rather than silently building clean because a prior
        # attempt was memoized before it actually warned.
        with _OFFLINE_UNSAFE_WARNED_LOCK:
            _OFFLINE_UNSAFE_WARNED.discard(key)
        raise