Skip to content

symfonic.core.prompt.blocks.taxonomy

taxonomy

The block taxonomy: tiers, permissions, and the canonical matrix.

This module is the vocabulary :class:~symfonic.core.prompt.blocks.spec.PromptBlockSpec is written in, and the single source of truth for what each well-known block is. It is deliberately separate from the spec model: the matrix is read by the validator, the resolver and the renderer, and a table that several components agree on should not live inside one of them.

Tiers, and what "learned" means

Four tiers, in descending authority: platform, operating, profile, session. The first two are authored -- a human wrote that text and the agent may never write it. The last two are learned: their content is aggregated from facts the system recorded about the user, so it is attacker-influenced input and the renderer wraps it accordingly.

"Learned" is therefore a classification over tiers (:data:LEARNED_TIERS), not a fifth tier value. Downstream code asks :attr:~symfonic.core.prompt.blocks.spec.PromptBlockSpec.is_learned rather than comparing tier strings, so adding a tier later cannot silently place new content on the authored side of the trust boundary.

Failure policy defaults follow that split

:func:default_on_source_failure returns fail_closed for the authored tiers and omit for the learned ones. An unreachable BOUNDARIES or IDENTITY source means the agent's rules are missing, and running without them is worse than not running; an unreachable USER_PROFILE means the assistant is less personal for a turn.

No destructive verb, anywhere

:data:AgentPermission has exactly four members and none of them is clear or delete. That is the point: a destructive verb the agent can reach is one hallucinated tool call away from erasing a user's profile, and no confirmation prompt exists in an agent loop to stop it. Removal happens through the memory layer's soft-retract path on the consolidation timeline, never through a block permission. :func:reject_destructive_permissions re-checks every permission set -- including :data:AgentPermission itself, at import -- so widening the literal later cannot quietly reintroduce one.

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.

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."
        )