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 |
| (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 production pairing: (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.
See examples/authored_skills/ for a runnable end-to-end reference.
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.
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¶
- 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.