Skip to content

symfonic.core.prompt.blocks.spec

spec

PromptBlockSpec -- the operator's declaration of one prompt block.

A block is declared, not discovered. The operator says which blocks exist, which tier each one belongs to, which source serves it, and who may write it. Nothing here reads a block; resolution lives in the resolver and rendering in the renderer. This is the contract they both read. The vocabulary it is written in -- tiers, permissions and the canonical matrix -- lives in :mod:symfonic.core.prompt.blocks.taxonomy and is re-exported here so a caller needs one import.

The name is the block id

:attr:PromptBlockSpec.name is handed to the source verbatim as block_id -- on :meth:~symfonic.core.prompt.blocks.protocol.BlockSource.load and on every history call. There is deliberately no second block_id field: a separate binding could drift from the name, and one shared source instance serving several blocks would then be asked for the wrong row while every spec still looked correct. :attr:PromptBlockSpec.block_id exists as a read-only alias so call sites can say what they mean, but it returns name and cannot be set independently.

Operator-editable is not agent-editable

:attr:PromptBlockSpec.operator_editable True means core exposes a write API to the operator for that block, which is why it requires a :class:~symfonic.core.prompt.blocks.protocol.WritableBlockSource -- history plus append, structurally present, not promised. It says nothing about the agent: no block-edit tool is registered in the agent's tool palette anywhere in this stage, on any construction path, regardless of this flag. For a delegated agent that is not merely true today but enforced: :mod:symfonic.agent.subagents.lockdown strips the self-edit request from every child config the parent builds, and rejects a pre-built child that arrives holding a block-edit tool. The vocabulary that guard reads -- :data:~symfonic.core.prompt.blocks.taxonomy.BLOCK_EDIT_TOOL_NAMES and :func:~symfonic.core.prompt.blocks.taxonomy.is_block_edit_tool_name -- is re-exported here with the rest of the taxonomy.

Every other invariant here is a construction-time error rather than a runtime check, for the same reason: a rule the agent could reach at runtime is a rule something can be argued into bypassing, while an invalid configuration simply does not start.

AUTHORED_TIERS module-attribute

AUTHORED_TIERS: frozenset[str] = frozenset(
    {"platform", "operating"}
)

Tiers whose content a human authored. The agent may never write these.

AgentPermission module-attribute

AgentPermission = Literal[
    "read", "append", "replace", "rewrite"
]

What the agent may do to a block.

Four verbs, and deliberately no fifth. There is no clear and no delete: see the module docstring.

BLOCK_EDIT_TOOL_NAMES module-attribute

BLOCK_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
    {
        "memory_block_append",
        "memory_block_replace",
        "memory_block_rewrite",
    }
)

The tools that let an agent write a block.

Named here rather than next to their (not yet written) implementations because the lockdown needs the names before the tools exist: a delegated child must be rejected for carrying one on the day the tool ships, not on the day someone remembers to update the guard. Stage 3 registers none of these on any construction path, so the set is currently a list of things nothing may hold -- which is exactly when it is cheap to install.

BLOCK_EDIT_TOOL_NAMESPACE module-attribute

BLOCK_EDIT_TOOL_NAMESPACE = 'memory_block_'

Tool-name prefix reserved for block writes. See :func:is_block_edit_tool_name.

BlockLayer module-attribute

BlockLayer = Literal['L1', 'L2']

Which cached prompt region the block renders into.

BlockScope module-attribute

BlockScope = Literal['deployment', 'tenant', 'profile']

How widely one block's content is shared.

deployment is one value for the whole install and is the only scope a scope_aware=False source can honour; that pairing rule is enforced by the config-level validator, which holds every spec at once.

BlockTier module-attribute

BlockTier = Literal[
    "platform", "operating", "profile", "session"
]

Authority tiers, highest first. See :data:LEARNED_TIERS.

CANONICAL_BLOCKS module-attribute

CANONICAL_BLOCKS: MappingProxyType[str, CanonicalBlock] = (
    MappingProxyType(
        {
            (b.name): b
            for b in (
                _canonical(
                    "BOUNDARIES",
                    "platform",
                    ("read",),
                    "L1",
                    10,
                ),
                _canonical(
                    "IDENTITY",
                    "platform",
                    ("read",),
                    "L1",
                    20,
                ),
                _canonical(
                    "RULES",
                    "operating",
                    ("read",),
                    "L1",
                    30,
                ),
                _canonical(
                    "ENVIRONMENT",
                    "operating",
                    ("read",),
                    "L1",
                    40,
                ),
                _canonical(
                    "USER_PROFILE",
                    "profile",
                    ("read", "append", "replace"),
                    "L1",
                    50,
                    label_prefix="SOUL:",
                ),
                _canonical(
                    "PREFERENCES",
                    "profile",
                    ("read", "append", "replace"),
                    "L1",
                    60,
                    label_prefix="PREFERENCE:",
                ),
                _canonical(
                    "FOCUS",
                    "session",
                    ("read", "append", "replace"),
                    "L2",
                    70,
                    label_prefix="FOCUS:",
                ),
                _canonical(
                    "ONBOARDING",
                    "operating",
                    ("read",),
                    "L1",
                    80,
                    inherit=False,
                ),
            )
        }
    )
)

The canonical block matrix, keyed by block name. Read-only at runtime.

DESTRUCTIVE_VERBS module-attribute

DESTRUCTIVE_VERBS: frozenset[str] = frozenset(
    {
        "clear",
        "delete",
        "drop",
        "erase",
        "purge",
        "remove",
        "reset",
        "truncate",
        "wipe",
    }
)

Verbs that may never appear in any permission set, at any tier.

LEARNED_TIERS module-attribute

LEARNED_TIERS: frozenset[str] = frozenset(
    {"profile", "session"}
)

Tiers whose content is aggregated from recorded facts.

The renderer treats these as untrusted data; the authored tiers render verbatim.

MEMORY_SOURCE module-attribute

MEMORY_SOURCE = 'memory'

Sentinel block source selecting the pinned memory lane.

SourceFailurePolicy module-attribute

SourceFailurePolicy = Literal[
    "fail_closed", "last_known_good", "omit"
]

What the resolver does when a block's source cannot be read.

CanonicalBlock dataclass

CanonicalBlock(
    name: str,
    tier: BlockTier,
    agent_permissions: frozenset[str],
    layer: BlockLayer,
    order: int,
    inherit: bool = True,
    label_prefix: str = "",
)

One row of the canonical block matrix.

A :class:~symfonic.core.prompt.blocks.spec.PromptBlockSpec naming one of these blocks inherits the row for every field it does not state itself, so an operator declaring ONBOARDING cannot forget inherit=False and an operator declaring BOUNDARIES cannot forget that it is platform tier.

PromptBlockSpec

Bases: BaseModel

One declared prompt block: what it is, who serves it, who may write it.

Frozen, because a spec is configuration: a block that could be re-tiered at runtime would make the platform-tier write ban a suggestion rather than an invariant.

Construction applies, in order:

  1. Canonical defaults. A name in :data:~symfonic.core.prompt.blocks.taxonomy.CANONICAL_BLOCKS supplies tier, permissions, layer, order and inherit for every one of those fields the caller omitted. Explicit values win, with two exceptions that only ever move in the safe direction: tier may not contradict the canonical row at all, and inherit may be narrowed to False but never widened to True against a canonical False (ONBOARDING).
  2. The tier failure default. on_source_failure omitted or None becomes :func:~symfonic.core.prompt.blocks.taxonomy.default_on_source_failure, so the field is concrete from here on.
  3. Invariants, each of which is a construction-time error.

block_id property

block_id: str

The id handed to the source -- :attr:name, always.

A property rather than a field so no configuration can set it to anything else: one source instance serving two specs distinguishes them by this value, and a settable copy could drift from the name while both specs still looked correct.

failure_policy property

failure_policy: SourceFailurePolicy

:attr:on_source_failure, narrowed -- never None after construction.

is_authored property

is_authored: bool

True for the authored tiers.

is_learned property

is_learned: bool

True for the learned tiers.

The renderer's trust boundary. Learned content is aggregated from recorded facts -- attacker-influenced input -- and is wrapped in untrusted-data delimiters; authored content renders verbatim.

model_copy

model_copy(
    *,
    update: dict[str, Any] | None = None,
    deep: bool = False,
) -> PromptBlockSpec

Copy the spec, re-running every invariant when a field changes.

Pydantic's own BaseModel.model_copy builds the copy by assigning into __dict__ directly and never calls a validator -- documented behaviour, not a bug in pydantic, but a hole in this class specifically: every invariant in this module exists to make a dangerous configuration "unrepresentable ... rather than a runtime check", and frozen=True only stops attribute assignment on an existing instance. spec.model_copy(update= {"agent_permissions": frozenset({"read", "append"})}) against a platform-tier BOUNDARIES block bypassed _check_permissions entirely and produced a writable authored block -- the exact shape the class docstring says cannot be constructed.

update=None (an exact duplicate, pydantic's own common case) is delegated to the base implementation unchanged: nothing about it can violate an invariant the original did not already satisfy. An update is instead applied by dumping the current field values, overlaying the update, and re-validating the result through the normal PromptBlockSpec(...) construction path -- so a copy can never hold a combination of fields the constructor itself would have refused.

Source code in src/symfonic/core/prompt/blocks/spec.py
def model_copy(
    self, *, update: dict[str, Any] | None = None, deep: bool = False
) -> PromptBlockSpec:
    """Copy the spec, re-running every invariant when a field changes.

    Pydantic's own ``BaseModel.model_copy`` builds the copy by
    assigning into ``__dict__`` directly and never calls a validator
    -- documented behaviour, not a bug in pydantic, but a hole in
    *this* class specifically: every invariant in this module exists
    to make a dangerous configuration "unrepresentable ... rather
    than a runtime check", and ``frozen=True`` only stops attribute
    assignment on an existing instance. ``spec.model_copy(update=
    {"agent_permissions": frozenset({"read", "append"})})`` against a
    platform-tier ``BOUNDARIES`` block bypassed ``_check_permissions``
    entirely and produced a writable authored block -- the exact
    shape the class docstring says cannot be constructed.

    ``update=None`` (an exact duplicate, pydantic's own common case)
    is delegated to the base implementation unchanged: nothing about
    it can violate an invariant the original did not already satisfy.
    An ``update`` is instead applied by dumping the current field
    values, overlaying the update, and re-validating the result
    through the normal ``PromptBlockSpec(...)`` construction path --
    so a copy can never hold a combination of fields the constructor
    itself would have refused.
    """
    if not update:
        return super().model_copy(deep=deep)
    merged = {**self.__dict__, **update}
    return type(self).model_validate(merged)

default_on_source_failure

default_on_source_failure(tier: str) -> SourceFailurePolicy

Return the failure policy a block of tier gets when it declares none.

fail_closed for the authored tiers (platform, operating): losing BOUNDARIES or RULES removes the agent's constraints, and an agent running without its constraints is worse than an agent that did not run.

omit for the learned tiers: losing USER_PROFILE costs personalisation for a turn, which does not justify failing the turn.

Source code in src/symfonic/core/prompt/blocks/taxonomy.py
def default_on_source_failure(tier: str) -> SourceFailurePolicy:
    """Return the failure policy a block of ``tier`` gets when it declares none.

    ``fail_closed`` for the authored tiers (``platform``, ``operating``):
    losing BOUNDARIES or RULES removes the agent's constraints, and an
    agent running without its constraints is worse than an agent that did
    not run.

    ``omit`` for the learned tiers: losing USER_PROFILE costs
    personalisation for a turn, which does not justify failing the turn.
    """
    return "fail_closed" if tier in AUTHORED_TIERS else "omit"

is_block_edit_tool_name

is_block_edit_tool_name(name: str) -> bool

Return True if a tool named name may write a prompt block.

Fail-closed on the whole :data:BLOCK_EDIT_TOOL_NAMESPACE prefix, not only the known names in :data:BLOCK_EDIT_TOOL_NAMES -- a later verb added under that namespace without updating the set is still caught. But the reserved namespace is core's own; an adopter tool that wraps :meth:~symfonic.core.prompt.blocks.protocol.WritableBlockSource.append_revision under an unrelated name (edit_block, write_block_custom) is invisible to a bare prefix check even though it is exactly the capability the lockdown exists to keep off a delegated child. This also matches the wider block_edit/edit_block/write_block/ update_block verb family the source-tree sweep enforces, so the two checks cannot silently diverge.

Matched case-insensitively, and against a name stripped of surrounding whitespace. Nothing on the path from a registered tool to this predicate normalises either: lockdown reads tool.name and passes it straight through, so MEMORY_BLOCK_APPEND and Edit_Block reached a delegated child while the lowercase spelling of the same tool was rejected. Block names already treat wrong case as a near-miss hazard and reject it by charset (:mod:~symfonic.core.prompt.blocks.spec); the tool-name path is the same hazard and gets the same answer. A guard that a shift key defeats is not a guard.

This remains a name heuristic, not a behavioural guarantee: it cannot detect a write tool given a name outside this vocabulary entirely (grant_edit, a translated or obfuscated name). No name check can -- see the module docstring on the reserved namespace.

Source code in src/symfonic/core/prompt/blocks/taxonomy.py
def is_block_edit_tool_name(name: str) -> bool:
    """Return ``True`` if a tool named ``name`` may write a prompt block.

    Fail-closed on the whole :data:`BLOCK_EDIT_TOOL_NAMESPACE` prefix,
    not only the known names in :data:`BLOCK_EDIT_TOOL_NAMES` -- a later
    verb added under that namespace without updating the set is still
    caught. But the reserved namespace is core's own; an adopter tool
    that wraps
    :meth:`~symfonic.core.prompt.blocks.protocol.WritableBlockSource.append_revision`
    under an unrelated name (``edit_block``, ``write_block_custom``) is
    invisible to a bare prefix check even though it is exactly the
    capability the lockdown exists to keep off a delegated child. This
    also matches the wider ``block_edit``/``edit_block``/``write_block``/
    ``update_block`` verb family the source-tree sweep enforces, so the
    two checks cannot silently diverge.

    Matched case-insensitively, and against a name stripped of
    surrounding whitespace. Nothing on the path from a registered tool to
    this predicate normalises either: ``lockdown`` reads ``tool.name`` and
    passes it straight through, so ``MEMORY_BLOCK_APPEND`` and
    ``Edit_Block`` reached a delegated child while the lowercase spelling
    of the same tool was rejected. Block *names* already treat wrong case
    as a near-miss hazard and reject it by charset
    (:mod:`~symfonic.core.prompt.blocks.spec`); the tool-name path is the
    same hazard and gets the same answer. A guard that a shift key
    defeats is not a guard.

    This remains a name heuristic, not a behavioural guarantee: it
    cannot detect a write tool given a name outside this vocabulary
    entirely (``grant_edit``, a translated or obfuscated name). No name
    check can -- see the module docstring on the reserved namespace.
    """
    if not isinstance(name, str):
        return False
    return bool(_BLOCK_EDIT_VERB_PATTERN.match(name.strip()))

reject_destructive_permissions

reject_destructive_permissions(
    permissions: frozenset[str], *, block_name: str
) -> None

Raise :class:ValueError if permissions holds a destructive verb.

:data:AgentPermission already excludes them, so this can only fire if the literal is widened later. That is exactly when it is worth having: the review that adds a verb sees a named failure instead of a silently-granted delete.

Source code in src/symfonic/core/prompt/blocks/taxonomy.py
def reject_destructive_permissions(permissions: frozenset[str], *, block_name: str) -> None:
    """Raise :class:`ValueError` if ``permissions`` holds a destructive verb.

    :data:`AgentPermission` already excludes them, so this can only fire
    if the literal is widened later. That is exactly when it is worth
    having: the review that adds a verb sees a named failure instead of a
    silently-granted delete.
    """
    offenders = sorted(v for v in permissions if v.lower() in DESTRUCTIVE_VERBS)
    if offenders:
        raise ValueError(
            f"block {block_name!r} declares the destructive permission(s) "
            f"{offenders!r}; no block grants a destructive verb at any tier. "
            "Removal happens through the memory layer's soft-retract path on the "
            "consolidation timeline, never through a block permission."
        )