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
¶
Tiers whose content a human authored. The agent may never write these.
AgentPermission
module-attribute
¶
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
¶
Tool-name prefix reserved for block writes. See :func:is_block_edit_tool_name.
BlockLayer
module-attribute
¶
Which cached prompt region the block renders into.
BlockScope
module-attribute
¶
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
¶
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
¶
Tiers whose content is aggregated from recorded facts.
The renderer treats these as untrusted data; the authored tiers render verbatim.
MEMORY_SOURCE
module-attribute
¶
Sentinel block source selecting the pinned memory lane.
SourceFailurePolicy
module-attribute
¶
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:
- Canonical defaults. A
namein :data:~symfonic.core.prompt.blocks.taxonomy.CANONICAL_BLOCKSsupplies tier, permissions, layer, order andinheritfor every one of those fields the caller omitted. Explicit values win, with two exceptions that only ever move in the safe direction:tiermay not contradict the canonical row at all, andinheritmay be narrowed toFalsebut never widened toTrueagainst a canonicalFalse(ONBOARDING). - The tier failure default.
on_source_failureomitted orNonebecomes :func:~symfonic.core.prompt.blocks.taxonomy.default_on_source_failure, so the field is concrete from here on. - Invariants, each of which is a construction-time error.
block_id
property
¶
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
¶
:attr:on_source_failure, narrowed -- never None after construction.
is_learned
property
¶
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 ¶
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
default_on_source_failure ¶
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
is_block_edit_tool_name ¶
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
reject_destructive_permissions ¶
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.