Guide 10 — Authored Procedural Skills¶
Version: v7.5.0+ Status: Stable Related: Guide 09 — Consolidation & Deep Sleep
Phase 12's autonomous learning is one source of procedural skills. The authored tier is the other: hand-authored skills that domain plugins seed at startup and the consolidation janitor leaves alone.
The two tiers coexist. They surface through the same query_skills
read path and the same CapabilityRouter prompt, so the LLM picks
between them transparently. They are distinguished only by the
source discriminator on each node's properties.
When to use authored skills¶
The autonomous (regex / LLM) path learns from observed behaviour. It cannot:
- Encode policy that has never been exercised in production.
- Express pre-flight rules whose violation has not yet caused a failure the system noticed.
- Carry domain-specific exception handling that requires expert judgement to articulate.
Authored skills cover that gap. Typical use cases:
- Pre-flight rules. "Before scheduling Slack delivery, call
get_integration_statusto confirm the channel is connected." - Guardrails. "Never write to the production database without a
prior
get_db_status()check that returnsready." - Domain procedures. Bootstrapping a new tenant, opening a support ticket, generating a compliance attestation.
Enforcement layers (calibration)¶
Read this before authoring skills with preconditions. The v7.5.1 release notes framed
PRE-FLIGHT:markers as a "hard ordering constraint overrequired_tool_ids". That claim is technically scoped to the capability-router LLM call, not the main brain LLM. In production, the strength of enforcement depends on which layers you opt into. Adopters who shipped the v7.5.1-v7.7.4 path expecting end-to-end enforcement (a production adopter's 3-probe wire evidence) found that the brain LLM treated PRE-FLIGHT as descriptive prose and never attempted the action tool — so the v7.7.4precondition_gatehad nothing to intercept. v7.7.5 closes that gap. The architecture below is what you actually get.
Where PRE-FLIGHT actually lands¶
| Marker site | Layer | Cached? | What sees it |
|---|---|---|---|
Router prompt at prompts/router.txt:19 |
L1 (router-LLM only) | Yes | Capability-router LLM when it selects tools for this turn |
MEMORY_CONTEXT at hms_system_l2.txt:16 |
L2 (brain-LLM) | No (volatile) | Brain LLM as part of the per-turn memory snapshot |
The router gate works because the router LLM is given a short prompt with the explicit ordering clause. The brain LLM sees the same marker as one line inside a volatile memory block — Anthropic models treat that as descriptive context, not imperative instruction.
Stratigraphic primer (L0 / L1 / L2)¶
| Layer | Source | Cached | Plugin position |
|---|---|---|---|
| L0 Brain kernel | Bundled hms_kernel.txt (framework-owned) |
Yes | RESERVED — position="kernel" is a no-op in v7.7.5 (see src/symfonic/core/plugins/contribution.py:20-26) |
| L1 OS services | hms_system_l1.txt + plugin contributions |
Yes | position="cached" lands here |
| L2 Per-turn task | hms_system_l2.txt + MEMORY_CONTEXT |
No | position="volatile" lands here |
The framework rule that makes this matter: Anthropic models follow L0/L1
imperatives ("YOU MUST CALL X BEFORE Y") much more reliably than L2
prose retrieved by vector similarity. The pattern an adopter's
_FAILURE_HANDLING directive uses (and which actually changes
model behaviour for them) is L1 position="cached" with named-section
imperative phrasing.
Three enforcement options¶
| Option | Knob | What it does | When to use |
|---|---|---|---|
| (a) Router gate | Automatic (v7.5.1) | Router LLM honours PRE-FLIGHT ordering when selecting tools | Always-on when enable_hms_prompt=True and the router LLM is wired |
(b) Brain precondition_gate |
procedural_enforce_preconditions=True (v7.7.4) |
LangGraph topology adds a third sibling node that intercepts attempted action tool_use and emits a synthetic ToolMessage(status="error") for unmet bare-id preconditions |
Catches non-compliance after the model attempts the action; does NOT induce attempts that never happened |
| (c) L1 imperative render | procedural_render_preflight_in_l1=True (v7.7.5) |
Framework synthesises a ## ENFORCED PRE-FLIGHT RULES block from active skills' bare-id preconditions and injects it at L1 position="cached" as imperative phrasing |
Lifts compliance: the brain LLM sees the rule as a directive and is dramatically more likely to attempt the precondition tool |
| (e) Governance objector (kernel) | governance(preconditions=[...]) |
The precondition is a governance rule at the pre-tool rung. An unmet one raises GovernanceRefused before the tool executes — the tool's own counter stays at zero — and the refusal names the rule in rule_ids |
The kernel path. Enforcement, not persuasion: unlike (a)-(c) it does not depend on what the model chose to do |
| (d) Manual escape hatch | None — adopter-owned BaseDomainPlugin.inject_contributions(position="cached") |
Adopter writes their own L1 directive at the plugin layer (e.g., an adopter's _FAILURE_HANDLING directive at the plugin layer) |
When you need failure-handling or domain-specific policy that isn't tied to a procedural skill |
Recommended pairing on the legacy path: (b) + (c). The L1 render lifts the model's likelihood of attempting the precondition; the gate catches the residual cases. Shipping (c) alone leaves the failure mode open if the model still skips the precondition. Shipping (b) alone leaves it open if the model never attempts the action.
On the kernel path, use (e), and keep rendering the rule. The two answer different questions and both are worth having. Rendering asks the model to comply; the objector does not ask. Render the skill as untrusted content — an operator wrote it into a store, so it is proposed text, and text a store can be made to hold is text an attacker can propose:
Agent(
provider,
tools=[...],
capabilities=[
PromptingCapability(sources=[StaticSource(text=rendered, untrusted=True)]),
governance(preconditions=[MyPreconditionObjector(...)]),
],
)
See examples/authored_skills/ for a runnable end-to-end reference: it
runs four turns and prints the tool's own execution counter for each.
The API: ProceduralLayer.seed_authored_skill¶
from symfonic.memory import ProceduralLayer
from symfonic.memory.models.entry import MemoryEntry
from symfonic.memory.types import MemoryLayer, TenantScope
scope = TenantScope(tenant_id="acme")
skill = MemoryEntry(
layer=MemoryLayer.PROCEDURAL,
tenant_id=scope.tenant_id,
content=(
"Before scheduling any delivery, confirm the integrations the "
"delivery channel relies on are connected for the active brand."
),
importance=7.0,
metadata={
"label": "Pre-flight: integration status check",
"steps": ["call get_integration_status(brand_id=<active>)"],
"context": "delivery scheduling pre-flight",
},
)
node = await procedural_layer.seed_authored_skill(
scope,
skill,
source_tag="authored:my-plugin",
precondition="before delivery-config, call get_integration_status",
)
seed_authored_skill is a thin convenience wrapper around
store_skill(... status="approved") that:
- Validates
source_tagstarts with"authored:"— anything else raisesValueErrorso the janitor guard is not silently bypassed on a typo. - Folds the source tag and optional precondition into
node.properties(properties["source"],properties["precondition"]). - Lands the result at
status="approved"(immediately active inquery_skills).
The convention is source_tag="authored:<plugin-name>". Multiple
domain plugins can coexist by picking distinct plugin names.
Direct construction (when you cannot use the wrapper)¶
If you need to set fields the wrapper does not expose, use
store_skill directly and set the source tag in metadata yourself:
skill.metadata["source"] = "authored:my-plugin"
await procedural_layer.store_skill(scope, skill, status="approved")
The janitor's authored-tier guard recognises any node whose
properties["source"] starts with authored:, regardless of which
write path produced it.
The janitor guard¶
SemanticMerge is the deduplication janitor that runs inside every
store_skill write and during the bulk
POST /procedures/deduplicate endpoint. Pre-v7.5 it could absorb a
hand-authored canonical into an auto-promoted draft (or vice versa) if
the labels or content drifted close enough.
v7.5.0 adds an explicit guard:
SemanticMerge.find_duplicatesreturns[]when the incoming node carries anauthored:source tag (authored writes always produce fresh rows, never merge into pre-existing entries).SemanticMerge.find_duplicatesskips authored entries in the existing-nodes scan (auto-promoted drafts cannot absorb a hand-edited canonical).SemanticMerge.deduplicate_allskips authored primaries (bulk dedup never mutates the authored tier).
The net effect: authored skills are immutable to the janitor. Their labels, steps, content, and precondition are preserved exactly as the seeding code wrote them.
Optional pre-flight precondition¶
The precondition field captures one or more pre-flight rules. The
common shape is a single one-line natural-language rule; v7.6.2
widened the field to also accept an ordered list of rules so a single
skill can encode multi-step pre-flights. When present, the field:
- Lands on the procedural node at
properties["precondition"]. - Surfaces through
query_skillsinentry.metadata["precondition"](same shape that was stored — consumers shouldisinstance-check bothstrandlist). - Is rendered into the
CapabilityRouterprompt as one indentedPRE-FLIGHT:marker per rule, and the prompt instructs the LLM that EVERY named pre-flight tool MUST appear inrequired_tool_idsBEFORE the action tool, in the order the markers appear. The rules are hard ordering constraints at the router LLM; brain-LLM enforcement requires one of the three opt-ins documented in Enforcement layers (calibration) above.
Single rule (v7.5+ shape)¶
await procedural_layer.seed_authored_skill(
scope,
skill,
source_tag="authored:my-plugin",
precondition="before delivery-config, call get_integration_status",
)
Renders as:
## 4. RELEVANT SKILLS FROM MEMORY
- Before scheduling any delivery... (steps: ['call get_integration_status(...)'])
PRE-FLIGHT: before delivery-config, call get_integration_status
Multi-rule (v7.6.2+ shape)¶
await procedural_layer.seed_authored_skill(
scope,
skill,
source_tag="authored:my-plugin",
precondition=[
"call get_integration_status(brand_id=<active>)",
"call get_brand_quota(brand_id=<active>)",
],
)
Renders as:
## 4. RELEVANT SKILLS FROM MEMORY
- Before scheduling weekly delivery... (steps: ['call create_scheduled_task(...)'])
PRE-FLIGHT: call get_integration_status(brand_id=<active>)
PRE-FLIGHT: call get_brand_quota(brand_id=<active>)
The LLM is instructed to include every pre-flight tool in
required_tool_ids in the order shown, before the action tool. A
single-element list is collapsed back to a string at storage time so
the prompt-cache stays warm for callers who happen to pass
1-element lists. An all-empty list is treated as no precondition.
Scoped rules: state predicates¶
A precondition entry may be a state predicate instead of a rule string — a way of saying this rule is only about some accounts:
await procedural_layer.seed_authored_skill(
scope,
skill,
source_tag="authored:my-plugin",
precondition=[
"get_integration_status",
{"state": {"path": "user_profile.region", "op": "eq", "value": "US"}},
],
)
Ask whether a stored rule applies to the state in front of you with
symfonic.memory.skill_applies:
from symfonic.memory import skill_applies
precondition = entry.metadata["precondition"]
if skill_applies({"user_profile": {"region": "EU"}}, precondition):
... # not reached: this rule is about US accounts
Where the state comes from¶
A turn declares what the deployment knows about it, and governance hands that
to every rule as subject.properties:
await agent.run(
"Announce that we ship Friday.",
state={"user_profile": {"region": "US"}, "verified": False},
)
class PreconditionObjector:
name = "authored_skill_preflight"
tool_name = "publish_announcement"
def __init__(self, precondition):
self._precondition = precondition
def check(self, call, subject):
# Does this rule apply here? Only then, is it satisfied?
if not skill_applies(subject.properties, self._precondition):
return None
if subject.properties.get("verified"):
return None
return "get_integration_status must be called first"
Four properties of that snapshot, and each exists to close a way it could otherwise become a back channel:
- Per turn, not per agent. Two turns on one compiled agent can declare different states without rebuilding a single rule — and a rule cannot carry a fact from one turn into the next.
- Frozen, recursively. One rule cannot rewrite a fact the next rule reads. A stage says what it decided by returning a verdict.
- Copied at the boundary. A caller who keeps the dict they passed cannot change a turn already under way.
- Never printed. The field is excluded from the request's
repr, so an account region does not travel into a log line or a traceback frame.
Passing something that is not a mapping raises at the call site rather than reading as empty three rungs later — a rule that silently sees no properties is a rule that silently stops applying.
| Name | Answers |
|---|---|
skill_applies(state, precondition) |
do all of a skill's predicates hold? |
state_predicates_in(precondition) |
the predicates in a list, unwrapped from {"state": ...} |
evaluate_state_predicate(state, predicate) |
one predicate → PredicateOutcome(matched, reason) |
Ask "does this apply?" before "is it satisfied?" Reversing the two
refuses an EU account for failing a rule written about US accounts.
examples/authored_skills/ shows both orders' consequences as separate
turns.
Operators: eq, neq, in, exists. Paths are dotted and resolve
UPPER-then-lower per segment, because prompt-render state arrives
upper-snake and runtime state arrives snake_case — a rule written
against one keeps matching under the other.
Evaluation fails closed. A missing path, an unknown operator, a
missing value, or an in whose value is a bare string (which would
silently substring-match, so region in "USA" would admit "US") all
evaluate to "does not match". A rule nobody can evaluate does not
admit, because the alternative is a filter that widens whenever
someone mistypes it. PredicateOutcome.reason says which of these
happened.
These functions were private until 2026-09-01. The only evaluator lived inside the legacy
precondition_gateLangGraph node, so a rule of this shape could be stored through the public contract and answered by nothing public. That node now calls the same functions — one interpreter of the DSL rather than two that drift apart.
Constraints (HTTP)¶
POST /memories/procedural enforces:
- Single string:
len <= 500characters. - List:
len(list) <= 5entries, each entry a string withlen <= 500characters.
Skills without a precondition render byte-identical to the pre-7.5.1 bullet shape, so prompt-cache parity holds for adopters who never use the authored tier.
HTTP API surface¶
POST /memories/procedural accepts the same shape via the new optional
precondition and source fields on ProceduralCreateRequest:
{
"label": "Pre-flight: integration status check",
"content": "Before scheduling any delivery...",
"steps": ["call get_integration_status(brand_id=<active>)"],
"context": "delivery scheduling pre-flight",
"status": "approved",
"precondition": "before delivery-config, call get_integration_status",
"source": "authored:my-plugin"
}
precondition accepts the multi-rule shape too (v7.6.2+):
{
"label": "Weekly scheduled delivery",
"content": "Schedule the weekly Slack/email delivery...",
"steps": ["call create_scheduled_task(...)"],
"status": "approved",
"precondition": [
"call get_integration_status(brand_id=<active>)",
"call get_brand_quota(brand_id=<active>)"
],
"source": "authored:my-plugin"
}
The Pydantic validator enforces len(list) <= 5 and len(entry) <= 500
per entry; violations surface as HTTP 422 (Unprocessable Entity).
Same constraint as the in-process API: source values starting with
authored: opt into the janitor guard. Other source values are
stored verbatim with no special treatment.
Coexistence with Phase 12¶
Authored skills and auto-promoted drafts live in the same procedural
store and surface through the same query_skills projection. To
distinguish them, read entry.metadata["source"]:
| Source | Origin |
|---|---|
authored:<plugin> |
seed_authored_skill or the HTTP API |
phase_12_promotion |
Regex extractor (v6.1+) |
phase_12_llm_extractor |
LLM extractor (v7.6+) |
Authored skills are immune to dedup. Both Phase 12 sources still run
through SemanticMerge so repeated runs do not flood the queue.
See also¶
examples/authored_skills/— the runnable reference: one seeded rule, four turns, and the tool's own execution counter printed for each.- Guide 09 — Consolidation & Deep Sleep — Phase 12 detail, both extractor flavours, telemetry.
docs/concepts/state-scope-mismatch.md— case study 5 covers thebind_layersAPI that v7.4.6 added to reach the procedural layer fromSleepConsolidatorcleanly.