Skip to content

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_status to confirm the channel is connected."
  • Guardrails. "Never write to the production database without a prior get_db_status() check that returns ready."
  • 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 over required_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.4 precondition_gate had 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:

  1. Validates source_tag starts with "authored:" — anything else raises ValueError so the janitor guard is not silently bypassed on a typo.
  2. Folds the source tag and optional precondition into node.properties (properties["source"], properties["precondition"]).
  3. Lands the result at status="approved" (immediately active in query_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_duplicates returns [] when the incoming node carries an authored: source tag (authored writes always produce fresh rows, never merge into pre-existing entries).
  • SemanticMerge.find_duplicates skips authored entries in the existing-nodes scan (auto-promoted drafts cannot absorb a hand-edited canonical).
  • SemanticMerge.deduplicate_all skips 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_skills in entry.metadata["precondition"] (same shape that was stored — consumers should isinstance-check both str and list).
  • Is rendered into the CapabilityRouter prompt as one indented PRE-FLIGHT: marker per rule, and the prompt instructs the LLM that EVERY named pre-flight tool MUST appear in required_tool_ids BEFORE 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 <= 500 characters.
  • List: len(list) <= 5 entries, each entry a string with len <= 500 characters.

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