Skip to content

Guide 22 — Capability-Architecture Migration

Symfonic grew to roughly 90,000 lines in which one class knew about memory, prompts, tools, sessions, sub-agents, HTTP, and telemetry at once. The refactor splits that into six layers with one invocation pipeline underneath. This guide is the map: what you have, where its replacement lives, and when — if ever — you have to move.

Read this first: adoption is not retirement

The migration preserves compatibility surfaces while moving implementations. For the upcoming release documentation, read Moving to 11.0. That preview introduces no new deprecations and does not authorize removal of legacy code.

The preservation programme is tracked separately from runtime acceptance:

  • No feature was dropped. Every export, route, CLI command, event, extra, environment variable, and curated example in the preservation catalogue is preserved. A feature can only leave through an explicit recorded decision, and this program made zero such decisions. The row-by-row status is the Feature Preservation Reference.
  • No import path was vacated. Tier-1 and tier-2 import paths do not break within a major line. Where code moved, the old path still imports the same object; a move that vacated a path would have to ship its alias in the same release, registered with an owner, a rationale, and a review date.
  • Compatibility entry points remain importable; two no longer construct. From 11.3.0 SymfonicAgent(...) and AgentBuilder().build() raise EntryPointRemovedError before anything is built; their imports still resolve. The other entry points' presence does not imply that every historical configuration argument is accepted. Existing validation and retirement refusals still apply.
  • State compatibility is path-specific. Test sessions, checkpoints and outstanding pauses against the exact routes you deploy. In particular, legacy pause tokens are not universally redeemable by kernel-native checkpoint paths. The continuation migration window retains a legacy destination and is bounded by that destination's retirement contract.

So this is not a "port your code by Friday" guide. It is a ladder you climb only as far as you need.

Before choosing a replacement, consult the behavior parity table. It links the executable comparisons, native migration recipes, and unresolved legacy-default rows, with the evidence boundary for each behavior.

The ladder

Rung You are working with Import root
1 The simple agent — one provider, tools, one turn symfonic
2 Capabilities — tools, prompts, memory, knowledge, delegation, human interaction, safety symfonic.capabilities.*
3 Runtime services — sessions, checkpoints, budgets, cost, consolidation, observability fan-out symfonic.services.*
4 Platform — tenancy, authn/z, HTTP and SSE, privacy, billing symfonic.platform, symfonic.agent.fastapi
5 Integrations — provider SDKs, embeddings, storage backends, MCP, plugins, OpenTelemetry symfonic.core.providers, symfonic.memory.backends, symfonic.capabilities.extensions, symfonic.observability
6 Low-level core — the kernel, contracts, the event stream symfonic.kernel, symfonic.core.contracts

Two rules make the ladder navigable:

  1. Dependencies point inward. A capability may import kernel contracts and its own package — nothing else. The kernel imports no provider SDK, no memory implementation, no transport, no exporter. Collaboration between capabilities is not an import edge; it is a port declared to the compiler and wired into the compiled plan.
  2. Absence means disabled. Optional behaviour attaches by passing a typed configuration object, not by flipping a boolean on a mega-config. No module under symfonic reaches for a disabled integration, so a cold container pays for nothing it asked symfonic for. (That is an attribution guarantee, and deliberately so: transitive requirements put modules in sys.modules before symfonic runs a line, and no change here can make a blanket "nothing is imported" claim true.)

Start at rung 1

from symfonic import Agent

agent = Agent(provider, tools=[...])
result = await agent.run("...")

See Guide 00 — The Simple Agent. Code that constructs SymfonicAgent stops at that call from 11.3.0; move it to Agent with the capabilities below, or to symfonic.platform.AgentHost for a hosted application, as Moving to 11.0 describes.

Which child declaration, and why it matters

delegated_children takes two kinds of declaration, and picking the wrong one is a tenant-isolation bug rather than a style preference.

ScopedChild(build=...) PrebuiltChild(agent=...)
What you pass a builder, scope -> agent a finished agent
Built once per tenant scope, inside compose once, wherever you made it
Use it for any child with state or tenant-bound capabilities stateless specialists, remote proxies, test doubles

A stateful child must be a ScopedChild. A finished agent is built with one scope, and a composition root has two places to build one — the convenient place being next to the provider, above compose. Do that with a child that has memory and it answers one tenant's delegation out of another tenant's recall. Measured, not feared: see tests/capabilities/delegation/test_scoped_children.py.

Nothing checks this for you. Agent exposes close, run and stream and nothing about what it was composed with, so a declaration-time gate cannot tell a stateless child from one holding scope-bound memory. The lockdown check that does exist reads a child's tools, which are reachable; its capabilities are not. Until that changes this is a rule you apply, not one the framework enforces.

Then rung 2: one call per capability

Every capability package has a single entry point. You pass what only your deployment can supply, and the call composes the rest.

from symfonic import Agent
from symfonic.agent.domain import CorePreference
from symfonic.capabilities.delegation import ScopedChild, delegated_children
from symfonic.capabilities.human import PauseBinding, human_interaction
from symfonic.capabilities.knowledge import knowledge_sources
from symfonic.capabilities.memory import GraphBackedHms, memory_capabilities
from symfonic.capabilities.prompting import (
    DomainPersona, Guardrail, PromptingCapability,
    guardrail_sources, persona_sources,
)
from symfonic.capabilities.tools import ToolsCapability, keyword_router, tool_name
from symfonic.platform import core_preference_migration, extensions, governance

legacy_preferences = core_preference_migration([
    CorePreference(
        statement="Confirm with an owner before changing stock.",
        sensitive_tags=["inventory", "write"],
        priority=9,
    ),
])

agent = Agent(
    provider,
    tools=TOOLS,
    capabilities=[
        *memory_capabilities(GraphBackedHms(graph_backend), scope),
        PromptingCapability(sources=[
            *persona_sources(DomainPersona(name="store", role="copilot")),
            *guardrail_sources(legacy_preferences.guardrails),
            *knowledge_sources(retriever=retriever, query=question),
        ]),
        ToolsCapability(
            entries_for=keyword_router(KEYWORDS, registered=names),
            registered=names,
        ),
        delegated_children([ScopedChild(name="researcher", build=build_child,
                                        description="...", when_to_use="...")],
                           scope=scope),
        governance(
            reflector=reflector,
            meter=meter,
            sensitive_terms=legacy_preferences.sensitive_terms,
        ),
        extensions([McpExtensionAdapter(servers=servers)]),
    ],
)

Add a durable conversation window

The native window is opt-in. Create a PostgresConversationLog on the same PostgresPoolManager used by the deployment, call ensure_schema() at startup, and pass a NativeConversationHistory to memory_capabilities. The factory adds its final publication stage. recent_turns counts individual utterances, and zero skips window reads.

from symfonic.capabilities.memory.conversation_history import (
    NativeConversationHistory, SessionScopeAuthorizer,
)
from symfonic.memory.backends.postgres_conversation import PostgresConversationLog

log = PostgresConversationLog(pool)
await log.ensure_schema()
scope = MemoryScope("acme", "alice", "chat-1")
history = NativeConversationHistory(log, SessionScopeAuthorizer(), scope)
capabilities = memory_capabilities(
    store, scope, conversation=history, recent_turns=6,
)
agent = Agent(provider, capabilities=[*capabilities, PromptingCapability()])
await agent.run("Hello", session_id="chat-1")

SessionScopeAuthorizer accepts only a request whose session ID matches the authenticated session-level scope. For agents shared across sessions under a principal scope, supply a ConversationAuthorizer that verifies each request's session ownership. Wire the same log into MemoryAdminService(store, conversation=log) so its forget(scope) includes transcript rows; GraphAdminService(graph, admin).export(scope) then includes them as well.

The window renders first. A recalled episodic copy of a turn the window already shows, matched by its turn_id (the run's run_id, which the built-in native conversation history reports back) and speaker, is left out of the block and reported in the hydration's dropped as "already rendered by the conversation window". Only window lines the block actually renders count: a turn the block's character or byte ceiling leaves out does not suppress its recalled copy. The match is by identity, not text: the same words from another turn, the other side of a window turn, and classified or semantic memories still render. The remaining recall keeps retrieval's ranked order; it is not re-sorted by time.

Suppression happens after retrieval has selected its results, so a top-ranked duplicate still occupies one of retrieval's limit slots and can crowd out an older, distinct memory: earlier memories and facts are never removed by this rule, but they are not guaranteed a slot. The behaviour above is verified for the normal hydration block with no custom compressor. A HydrationPolicy(compressor=...) you configure runs after suppression, on the assembled block, and may rewrite or drop a window line; duplicate suppression and source preservation are not guaranteed after arbitrary custom compression. Ordering the conversation window oldest first (CORE-65) is a separate requirement from recall order.

Add post-consolidation reflection

MemoryReflectionService extracts durable insights after the ordinary memory flush. It is not the governance(reflector=...) collaborator: governance metacognition evaluates a draft before egress, while reflection receives the completed exchange and can persist a semantic record. Compose it explicitly so the reflection model, prompt bytes, store, and scope are visible at one root.

from symfonic.capabilities.memory import MemoryReflectionService, memory_capabilities

reflection = MemoryReflectionService(
    reflection_model,
    prompt_template="Extract delivery insights.\n{conversation}\nReturn JSON array.",
)
agent = Agent(
    provider,
    capabilities=memory_capabilities(store, scope, reflection=reflection),
)

Leaving reflection= out disables the phase and never calls reflection_model. The runnable reflection migration example uses only public imports and asserts the persisted semantic insight.

Add bounded graph activation

An activation composition has one public surface. Supply an AssociationSource that returns the graph edges for each frontier, build a SpreadingActivation around it, then pass it to memory_capabilities. The factory keeps the normal direct recall and adds only the bounded neighbours the activation admits.

from symfonic.capabilities.memory import MemoryScope, memory_capabilities
from symfonic.capabilities.memory.recall import (
    Association,
    AssociationSource,
    SpreadingActivation,
)


class Relationships(AssociationSource):
    async def neighbours(
        self, scope: MemoryScope, record_ids: tuple[str, ...]
    ) -> tuple[Association, ...]:
        return ()  # Query the deployment's graph for this scope and frontier.


activation = SpreadingActivation(source=Relationships(), max_hops=1, max_nodes=10)
capabilities = memory_capabilities(store, scope, activation=activation)

Tune native retrieval without reviving FrameworkConfig

The public memory factory accepts a RetrievalPolicy, so floors and hierarchy ranking are applied by the coordinator before the prompt compiler sees a recall block. For an adopter-owned scorer, construct the coordinator over a CandidateSource and pass it as retrieval=. That coordinator is the native hot path; a pre-ranked store keeps its own ordering, while an adopter source lets the policy order candidates.

from symfonic.capabilities.memory import memory_capabilities
from symfonic.capabilities.memory.retrieval import (
    CandidateSource,
    RetrievalCoordinator,
    RetrievalPolicy,
    RelevanceWeights,
    ScopeBlend,
    ScopeBlendMode,
)

policy = RetrievalPolicy(
    min_relevance=0.45,
    weights=RelevanceWeights(cue=1.0, salience=0.0),
    blend=ScopeBlend(mode=ScopeBlendMode.BACKFILL),
)
retrieval = RetrievalCoordinator(source=my_candidate_source, policy=policy)
capabilities = memory_capabilities(store, scope, retrieval=retrieval)

exclude_speakers=frozenset({"assistant"}) filters only turns returned by an adopter ConversationSource; recalled records have no speaker schema and are not inferred from quoted text. A policy whose enabled_layers leaves a layer out, or a query whose layers does, also keeps the built-in SpreadingActivation from walking into that layer: no neighbour there is recalled, logged or reached through. A custom activation is not bounded this way. Pass LearningPolicy(phase1_spreading_weight=…) to quick_phases(..., policy=...) to configure Phase 1 at the native nap factory.

Add an explicit JIT STM summary

WorkingMemorySummaryService consumes an adopter-owned async evicted(scope) source. It never borrows the response model: mode="llm" requires an explicit model provider, while the default extractive mode is local. The contribution is materialized for the request scope and admitted only by ContextStrategy.JIT, so it cannot leak into a stratified prompt.

from symfonic.capabilities.memory import WorkingMemorySummaryService, memory_capabilities

summary = WorkingMemorySummaryService(evicted_turn_source, mode="extractive")
capabilities = memory_capabilities(store, scope, summary=summary)

Omit summary= to disable the feature without calling the evicted-turn source. See the runnable JIT STM summary example.

Observe successful episodic publication

The retired FrameworkConfig.episodic_telemetry_sink setting does not configure the capability route. Compose an async EpisodicTelemetrySink with the public memory factory instead:

from symfonic.capabilities.memory import (
    EpisodicLifecycleEvent, EpisodicTelemetrySink, memory_capabilities,
)


class AuditSink(EpisodicTelemetrySink):
    async def emit(self, event: EpisodicLifecycleEvent) -> None:
        # Forward identifiers to the deployment's telemetry service.
        await audit.publish(event.scope_path, event.run_id, event.committed_record_ids)


capabilities = memory_capabilities(
    store,
    scope,
    episodic_telemetry_sink=AuditSink(),
)

The sink is called only after memory.lifecycle successfully publishes records. Its receipt contains the flush scope, lifecycle run lineage, and committed record ids; it never contains memory text, prompts, responses, or metadata. Sink failures are logged without payload and do not roll back publication. Omit the argument to disable telemetry without creating lifecycle telemetry work.

examples.migration_parity.episodic_telemetry is a runnable, offline adopter example:

# repository only: this migration probe is intentionally not part of the public sdist.
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.episodic_telemetry

Association, AssociationSource, and SpreadingActivation are imported from symfonic.capabilities.memory.recall. The four conversation-window types share that surface: ConversationSource, ConversationTurn, WorkingContext, and WorkingWindow. Their established imports from symfonic.capabilities.memory remain supported for compatibility.

Migrating an existing deployment

These recipes are for a deployment that already has its own persisted scope keys, transcript store, or fabrication checks. They preserve the real adapter boundary instead of treating the new types as a second store or a detector replacement. Each module contains assertions for the shown result and runs without a model, network, or database:

PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.legacy_scope_bridge
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.conversation_continuity
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.fabrication_adapter

1. Preserve legacy scope keys

Read an existing key with scope_from_legacy_path, use the returned MemoryScope in the native composition, and write the legacy shape only at a compatibility boundary. The recipe proves this exact round trip:

from symfonic.capabilities.memory import legacy_scope_path, scope_from_legacy_path

legacy = "tenant\x1facme\x1fsub_tenant\x1fclient1"
scope = scope_from_legacy_path(legacy)
assert scope.path == "acme/client1"
assert legacy_scope_path(scope) == legacy

The separator and the historical level labels belong to the legacy persistence format. Do not derive a scope by splitting on /; use the compatibility pair so a rollback continues to read rows written during migration. See the executable legacy_scope_bridge.py.

2. Adapt the transcript store to ConversationSource

The working window reads recency-ordered turns from the deployment's store; it does not create a new transcript store or infer speakers from recalled memory. Implement the focused public protocol and pass it to WorkingWindow:

from collections.abc import Sequence

from symfonic.capabilities.memory import MemoryScope
from symfonic.capabilities.memory.recall import (
    ConversationSource, ConversationTurn, WorkingWindow,
)


class TranscriptStore:
    async def recent(
        self, scope: MemoryScope, limit: int,
    ) -> Sequence[ConversationTurn]:
        rows = await transcript_db.last_turns(scope.path, limit)
        return tuple(
            ConversationTurn(turn_id=row.id, speaker=row.role, text=row.text)
            for row in rows
        )


window = WorkingWindow(source=TranscriptStore(), recent_turns=2)

Return turns oldest first and preserve the store's real IDs and ordering. Recall recognises a window turn only by its turn_id and speaker, and Symfonic stores a turn's episodic copy under the run's run_id; a store whose IDs differ from that run_id keeps rendering the recalled duplicate. The fully runnable recipe uses an in-memory stand-in only so it can assert the rendered two-turn window; replace that query with the deployment's transcript read. See conversation_continuity.py.

3. Reuse a legacy fabrication implementation through governance

FabricationDetector is a small public port. The adapter in the runnable recipe calls detect_fabrication and maps each actual legacy finding's kind, reason, and confidence into public Finding values. GovernanceCapability then produces the verified public REFUSE verdict for the sample unsupported claim at the legacy confidence of 0.8.

This is an adapter, not a claim of complete legacy equivalence: the public detector port receives only draft, grounding, and public tool-call records. It does not invent the legacy function's optional user_message, intent verdict, or identifier-key configuration. Provide identifier keys explicitly when the deployment has them, and keep any behavior needing the omitted inputs on its legacy route until that input has a public contract. See the executable fabrication_adapter.py.

activation_log_mode is unrelated to these three adapter paths. It remains a retired legacy-entry-point setting: non-default activation-label behavior is not reintroduced by this guide, and its migration decision remains tracked separately under issue #175.

Capability Entry point What only you can supply
Memory memory_capabilities(store, scope) the store and the scope
Prompting persona_sources(), guardrail_sources() who the agent is, and its rules
Tools keyword_router() + ToolsCapability which words surface which tool
Delegation delegated_children([...]) the children, already built
Human interaction human_interaction(signer=, binding=, encode_token=) see below
Governance platform.governance(**ports) a reflector, a meter, objectors
Knowledge knowledge_sources(...) → prompting a retriever, a store, documents
Extensions platform.extensions([...]) the adapters
Telemetry platform.telemetry(services, scope) the observability bundle and the scope

Two of them sit in symfonic.platform rather than in their own package. That is not an inconsistency: governance and extensions are each contained by a rule their own suites enforce — a module inside may import only from inside — and a contribute() needs the kernel's contribution contracts. Composition is the platform layer's job, so the binding sits at the boundary and the packages stay pure.

Write an adopter capability

CapabilityConfig is structural: any object with contribute(request) -> CapabilityContribution can be passed to Agent(capabilities=[...]). The public authoring types live at symfonic.capabilities:

from symfonic import Agent, ContractViolationError
from symfonic.capabilities import (
    CapabilityContribution,
    Phase,
    StageDescriptor,
    no_change,
)


class BudgetExceeded(ContractViolationError):
    preserve_contract_identity = True


class BudgetCapability:
    def __init__(self, tracker, tenant_id):
        self._tracker = tracker
        self._tenant_id = tenant_id

    def contribute(self, request):
        stage_id = "adopter.budget"
        stage = StageDescriptor(
            stage_id=stage_id,
            phase=Phase.PRE_MODEL,
            capability=stage_id,
        )

        async def enforce(context):
            decision = await self._tracker.check_budget(self._tenant_id)
            if not decision.allowed:
                raise BudgetExceeded(f"budget exceeded: {decision.reason}")
            return no_change(
                "tenant is within budget",
                counts={"checked": 1},
            )

        return CapabilityContribution(
            capability=stage_id,
            stages=(stage,),
            handlers={stage_id: enforce},
        )


agent = Agent(
    provider,
    capabilities=[BudgetCapability(tracker, tenant_id="acme")],
)

PRE_MODEL runs once per model round, immediately before the provider call. That makes it the right phase for a cumulative tracker that is updated between rounds: an over-budget handler stops the turn before another model request. An ordinary handler exception is wrapped as ContractViolationError and kept as its __cause__. When callers need to distinguish a deliberate refusal, subclass the public ContractViolationError and set preserve_contract_identity = True, as BudgetExceeded does above; that exact exception reaches the caller through the Agent facade from PRE_MODEL, POST_MODEL, PRE_TOOL, POST_TOOL, and FINALIZE. PROMPT_ASSEMBLY is the exception: its guard always wraps a handler failure, including an opted-in subclass, and retains the original as __cause__. This gate also cannot interrupt a completion already in flight, so keep the provider's own output limit configured.

The six adopter-facing phases are, in order: PROMPT_ASSEMBLY, PRE_MODEL, POST_MODEL, PRE_TOOL, POST_TOOL, and FINALIZE. BIND and TEARDOWN are kernel-owned and reject adopter stages. A handler that observes without changing the turn returns no_change(reason, counts=...). The other phases' mutation envelopes remain capability-specific; use a shipped capability as the starting point when the handler needs to alter prompts, model output, tool calls, or final results.

Knowledge is not a capability. Its four sources have read(), which is the shape the prompting sources have, so retrieved documents reach the model through the prompt compiler rather than through a second stage competing for the same budget.

human_interaction requires three arguments and defaults the rest, because the capability contributes nothing without them and a factory that returned that silently would hand you an agent whose model never sees ask_user. A shared signer would make every deployment honour every other's tokens; a PauseBinding with empty fields is a token bound to nothing — pause refuses one, saying a token bound to no checkpoint could never recover its own request; and the token format is yours to choose. Read the pause off stream(): the blocking run() returns one finished result and has no shape for a pause.

Bind the pause to the run that is pausing with current_run_binding. binding takes no arguments and is called by the pause stage during the turn. You own the pin, scope, session, thread and checkpoint and close over them. The run's own ids come from the turn:

from symfonic.capabilities.human import current_run_binding, human_interaction
from symfonic.platform import EnvelopeSigner, InvocationPin, Keyset, KeyState

keys = Keyset()
keys.provision("app", secret=your_signing_secret, state=KeyState.ACTIVE)
signer = EnvelopeSigner(keys, producer_package_version="app")
pin = InvocationPin(...)  # the pin your deployment issues

human_interaction(
    signer=signer,
    encode_token=encode,
    decode_token=decode,
    binding=lambda: current_run_binding(
        pin=pin, scope=scope, session_id=session_id, thread_id=thread_id,
    ),
)

run_id is the pausing turn's id and root_run_id is its root, or the turn itself when it is top-level. The helper takes no run_id and no root_run_id, so passing one is a TypeError. It keeps only the two strings, not the run. Called outside a turn, for example while composing the agent, it raises InteractionConfigurationError instead of returning a binding with no run. It proves nothing: AgentHost.resume still checks the token, the run's lineage and the single claim, and a run it cannot prove is refused as before. A hand-written PauseBinding(...) behaves exactly as it did.

Only the two run ids come from the turn. The pin, scope, session, thread and checkpoint are yours and are copied as given: they are signed into the pause, but the token, lineage and claim checks do not know which session or scope the resuming request expects. If a resume must belong to a particular session, compare it in your own authority (app-owned code, not something the SDK does for you).

EnvelopeSigner, InvocationPin, Keyset, KeyState and PinEnvelope on symfonic.platform are the framework's existing signer classes, the same objects, named on a public package so the snippet above needs no private import and no hand-written EnvelopeSignerPort. Nothing supplies a key: the secret is yours.

Your encode_token/decode_token choose the token format. To keep the token outside memory, serialize minted.envelope (a PinEnvelope: its fields, the pin's fields, the payload bytes and the signature) and build a new PinEnvelope from them when decoding; return None for anything you cannot parse. A rebuilt envelope is not trusted for being well-formed: the signer verifies the signature over every field and the payload before the pause is honoured, and a wrong key, edited field or missing signature is refused.

tests/platform/test_public_pause_bootstrap.py runs this composition on public imports only, adapters included (tests/platform/public_pause_ports.py stores the envelope as JSON in SQLite): a real Agent.stream pause, then AgentHost.resume in the same process, once. tests/platform/test_public_pause_codec.py shows forged and broken tokens refused before any model call, effect or claim. A fresh process, resume_stream, HTTP and budgets are not covered.

What these do not do yet

Stated here rather than left to be discovered:

  • Credential hygiene examines nothing at ingress, and its real work is one rung later. A kernel turn request carries prompt, history, scope and attachments, none of which is the key/value bag it scrubs; the arguments of a call about to be admitted are. It is carried onto every rung for that reason, and a credential-shaped key stops the call before the tool sees it.
  • An extension's policies are refused, not accepted and ignored. Nothing on a turn consults a plugin before an action, so composing one would leave you believing you have a veto you do not have.
  • A fail-open stage that asks for a change it cannot make is allowed through, and says so in a diagnostic. A reflection that requested a revision and produced none is not the same as a critic that approved.
  • A turn composed without telemetry(...) is charged to nobody, and one composed without a scope is recorded against the run rather than a tenant, so no ceiling can fire. platform.attribution_is_bound answers which you have.

Trust, and the default that protects you

A source wrapped for the compiler lands on a learned tier unless it declares itself authored (untrusted = False on the source object, which every source in capabilities.prompting sets). Authored tiers render verbatim, so an unlabelled source — a retrieved document, a plugin's text — would otherwise be read to the model as instruction you wrote. The safe tier is the one you get without asking.


Domain-by-domain map

Each section names the legacy pattern (still supported), the layer that now owns the behaviour, and the module you reach for when you want the narrow contract instead of the facade. The D numbers are the preservation-catalogue domains; the reference carries the per-feature rows.

D1 — Model providers, routing, and tuning

Legacy pattern Replacement
symfonic.core.providers.AnthropicProvider (and the seven siblings) unchanged — provider adapters are an integration, and this is their canonical home
per-dispatch resolver hooks, role tables symfonic.services.models.ModelResolutionService — one precedence chain over per-dispatch hooks, role tables, caller defaults, and the provider's own declaration, recording which won
MultiProviderRouter unchanged — it is the ModelProvider that dispatches each call to a backing provider by model-name prefix. ModelResolutionService decides which ModelConfig a call uses and reads through the router to do it; the router then sends that config to the right provider. The two compose; neither replaces the other
"does this provider support thinking / forced tool choice / a custom http client?" answered by branching on the class describe_provider(), detect_provider_family(), cache_dialect_for()

Provider adapters keep their SDK imports behind their extras. The resolution service is pure: no I/O, no client construction, no credential reads.

D2 — Tools, registry, routing, policies, MCP

Legacy pattern Replacement
@symfonic_tool, ToolRegistry unchanged
tool metadata re-derived by whoever needed it symfonic.capabilities.tools.ToolCatalog — one reading of the registry, and the only place the agent-facing manifest is derived
intent triage, lazy routing, operator allowlists, role palettes, forced choice, registry policy each wired at a different call site one ordered SelectionStage pipeline (IntentRoutingStage, LazyRoutingStage, AllowlistStage, RolePaletteStage, ForcedChoiceStage, PolicyStage) that records what every stage decided
symfonic.tools.mcp wired by hand still there as the client; composition goes through symfonic.capabilities.extensions (D12)

The order is a list you can read, not an emergent property of where the code happened to live.

D3 — Prompts, context, and the prompt-block layer

Legacy pattern Replacement
PromptBuilder, CacheBlock, CacheablePrompt, the block surface unchanged, and public at three package roots: symfonic.core.prompt, symfonic.core.prompt.blocks and symfonic.core.prompt.blocks.sources. Each defines an __all__ that is the contract. Their submodules (sections, blocks.render, .resolver, .snapshot, .taxonomy) are internal — import from the package root, or your generated tests/test_public_api_imports.py will refuse the import
ContextManager / JITContextManager / StratifiedHMSContextManager chosen by strategy name symfonic.capabilities.prompting.compile_prompt — one pipeline: accept, validate, admit, resolve, gate, order, budget, region, freeze
every producer deciding its own position, budget share, and cache tier producers declare a contribution; position, budget, cache region, and untrusted-content wrapping are decided once, over the whole set

Untrusted content keeps its delimiter neutralisation, tier/trust mismatch errors, and the child self-edit denial — those are contracts, not defaults.

D4 — The five-layer HMS

Legacy pattern Replacement
symfonic.memory.MemoryOrchestrator, the five layers, GraphMemoryStore, embeddings, Postgres backends unchanged
the engine calling memory to hydrate, a post-response path calling it to extract, a scheduler calling it to consolidate symfonic.capabilities.memory — three narrow ports plus a bridge that declares stages instead of being called
"run this agent without memory" as a code fork absence of the capability
ranking, floors, scope blend, and spreading activation decided inline .coordinator / .hydration / .activation
extraction, owned writes, consolidation, promotion, entity linking .extraction, .writes, .consolidation

Everything these services persist stays readable by the legacy path (.compat), which is the property wave rollback depends on.

D5 — Knowledge, documents, attachments, multimodal

Legacy pattern Replacement
symfonic.knowledge provider protocol, InMemoryDocumentStore, symfonic.agent.attachments unchanged
a knowledge provider formatting its own citation lines; a document store building its own block; extractors returning unbounded text; OCR output going wherever the caller put it symfonic.capabilities.knowledge — each becomes a contribution that states what it supplies and how far it may be trusted, never where it goes
media-type normalisation duplicated per call site .multimodal / .outbound, with explicit allowed media types and URL schemes

D6 — History, sessions, transcripts, checkpoints, structured output

Legacy pattern Replacement
ConversationManager and the three strategies symfonic.services.conversation.history
SessionManager, get_transcript() .session, .transcript
symfonic.agent.checkpointer factories (memory / sqlite / postgres / mongo) unchanged as adapters; readiness, registry, and restart recovery are .checkpoint, .registry, .recovery
extract_structured_output unchanged; the kernel's StructuredOutputAdapter is the same behaviour on the compiled path, and Agent.run(output_type=...) is its simple form

State written by either path is readable by the other, through an additive envelope under one reserved metadata key.

D7 — Sub-agents, delegation, lockdown, snapshots

Legacy pattern Replacement
SubAgent, SubAgentSpec, SubAgentRegistry unchanged
inheritance, sanitisation, ownership, and duplicate refusal spread across a registry, a tool factory, three context variables on the parent, and a teardown sweep in two dunder methods symfonic.capabilities.delegation — declarations, a child compiler, a roster, a depth ceiling, a per-run scope, and the run_agent / list_agents tool surface
assert_no_block_edit_surface, deny_child_self_edit unchanged, and still unconditional: a child never gets a block-edit surface

D8 — ask_user, interrupts, pause tokens, resume

Legacy pattern Replacement
AskUserQuestionEvent / InterruptEvent contracts, POST /resume/{pause_token} unchanged
a tool module, a graph node, two contract modules, a token facade with a process-wide default manager, three consumption stores, and two nearly-identical resume methods symfonic.capabilities.human — registration, binding, tokens, consumption, ledger, drain, resume
replay resistance implemented per store one atomic conditional write on the adopter's backend, with a durability check

Expiry, replay resistance, and scope binding are hard invariants carrying mutation evidence: the suites are required to fail when the guarantee is deliberately broken.

D9 — Safety, governance, budgets, audit

Legacy pattern Replacement
a scrubber in agent/hygiene.py, an intent filter threaded through three engine entry points, a fabrication detector and a metacognitive critic in agent/middleware/, a precondition gate in core/nodes/, steering in the tool span seam symfonic.capabilities.governance — seven stages, one canonical order, one declared failure mode each, and one record of what was decided about a turn
budget checks inline on the hot path symfonic.services.budget (context-window budget: estimation, allocation, truncation — offline by construction) and symfonic.services.cost (money: may this scope incur another billable call)
AuditLogger module globals symfonic.platform.audit with an injected sink

Ordering is the one property no individual stage can enforce, so the pipeline refuses a composition that contradicts the rulebook.

D10 — Callbacks, events, streaming, metrics, cost, traces, OTel

Legacy pattern Replacement
CallbackHandler, the typed event contracts, symfonic.core.streaming unchanged
five consumers with five sources: hand-placed emission sites, a boolean capture flag read at four of them, two cost calculations, a trace context that only existed with an optional extra symfonic.services.observability — one source (KernelEvent), six narrow ports, one bridge that owns fan-out, error isolation, and terminal cardinality
symfonic.observability.otel unchanged; it is one observer among the six

An observability failure cannot fail a run, and with nobody watching the sink is None — the disabled path allocates nothing.

D11 — Tenancy, HTTP API, privacy, billing

Legacy pattern Replacement
the 40 routes on create_agent_router and friends (default prefix /api/v1) unchanged
set_tenant_auth_verifier as a module global symfonic.platform.scope.HeaderScopeResolver, constructed and injected, so two agents in one process no longer share one verifier (the legacy function still works)
str(exc).startswith("Budget exceeded:") branching plus a raw header read symfonic.platform.budget.BudgetService through the narrow BudgetCheck port with a typed refusal
privacy export/deletion coupled to the HTTP layer symfonic.platform.privacy at the edge, symfonic.services.privacy for the erasure fence and subject-store registry a background worker can use without importing HTTP
transport details spread through the routers symfonic.agent.fastapi.transport — codec, frames, SSE, error mapping, policies

The platform derives who the caller is, decides whether the request may proceed, and hands the request to the public facade. It implements nothing an invocation does.

D12 — Plugins, schedulers, CLI, scaffolding, examples, testing

Legacy pattern Replacement
BaseDomainPlugin + agent.load_plugin(...) mutating the engine symfonic.capabilities.extensions — an MCP server and a domain plugin are the same thing: code the framework did not write, wanting to add to what the agent can do. Both declare tools, prompt fragments, policies, and lifecycle hooks; compose() is a pure function to one frozen value. LegacyPluginBridge keeps existing plugins working
TaskSchedulerProtocol, NullScheduler unchanged (symfonic.infra)
symfonic CLI, symfonic init, templates unchanged
audit_hms, audit_hms_sync, AuditRunner (deprecated: they warn and still run) No equivalent. The supported path is symfonic doctor PROJECT_PATH --scope <tenant path> for a composed Agent or symfonic doctor --offline --config <file> for a configuration. Neither reproduces the five legacy checks: the doctor without --offline lists them as UNKNOWN, and --offline omits them. Plus symfonic.devtools for the architecture checks the repository runs on itself
MockChatModel, MockModelProvider unchanged

Installing an extension changes nothing else — which is now an assertion a test can make, because composition is a pure function rather than a mutation.


How to adopt incrementally

Migrating the legacy defaults

The legacy field list is not a list of always-running behaviours. The executable legacy-default parity table records each field's trigger, public composition, evidence, or the issue that still owns the gap. Start there before treating a default-valued legacy field as migration work.

The verified subset has one composition root:

from symfonic.migration import SUPPORTED_DEFAULT_BEHAVIORS, compose_legacy_defaults

agent = compose_legacy_defaults(
    provider,
    store=store,
    scope=scope,
    behaviors=SUPPORTED_DEFAULT_BEHAVIORS,
    conversation=conversation,
    extractor=extractor,
    activation=activation,
    consolidation=consolidation,
)

Those dependencies are deliberately required. Calling the factory with no behaviors requests the entire legacy-default claim and raises a named refusal for every unresolved row; it does not return an agent with defaults silently missing.

  1. Code already on Agent, AgentHost or a capability package needs no further move. Adopt another rung when it buys you something. This does not cover the legacy entry points: from 11.3.0 SymfonicAgent(...) and AgentBuilder().build() raise EntryPointRemovedError, so code that constructs either one must move as item 2 describes.
  2. New code starts at rung 1. from symfonic import Agent for stateless work. For memory, a conversation window or human interaction, pass those capabilities to the same Agent (rung 2, above); for tenancy and HTTP, host it with symfonic.platform.AgentHost. An existing FrameworkConfig carries over through symfonic.migration.agent_from_config. Do not drop to SymfonicAgent. symfonic-core==9.12.* is the newest public PyPI line on which either entry point still constructs (private 11.0 to 11.2.6 did too). It predates 11.x, so pinning it does not migrate an 11.x application.
  3. Take one capability at a time. Each has its own narrow contract and its own contract suite; you can adopt capabilities.tools without touching how you do memory.
  4. Move module globals to injection when you touch them. The auth verifier, the audit logger, and the budget tracker all have injected forms now. This is the one class of change that matters even when nothing breaks: process-global mutable configuration is why two agents in one process could share state they should not.
  5. Check yourself with the diagnostics. symfonic doctor --offline reports legacy configuration, missing extras, and architecture misconfiguration offline — no network, no hidden I/O. audit_hms is deprecated and warns, pointing at symfonic doctor as the supported path. The doctor does not reproduce its five checks (embedding_provider.stub, orchestrator.layers, chat_model.resolution, lazy_tooling.orphan, consolidate.schedule) either way: without --offline it lists each as UNKNOWN, and --offline omits them. A SymfonicAgent no longer constructs from 11.3.0, so there is no instance for audit_hms to audit there; an application that relied on audit_hms on an earlier line loses those findings when it moves to the doctor, including the EMBEDDING_PROVIDER stub error.
symfonic doctor --offline . \
    --config my-config.json \
    --migration-report migration-inspection.md

--offline answers eight questions without constructing an agent, importing a line of your project, or opening a socket: which of your configuration keys are legacy spellings and what each becomes, which cross-capability rules your configuration breaks, which packaging extras it commits you to that are not installed, which state a restart or a second replica will lose, which consequential values you never chose, which imports have a documented new owner, where the production auth gate will not fire, and which imports reach past what the package declares. --config is optional: without it the source and platform checks still run, and every question with no input is reported as not inspected rather than as clean.

Run it before you change anything. It is the list this guide's domain-by-domain map applies to your project.

If something is missing

It should not be. If you cannot find a feature in the Feature Preservation Reference, that is a defect in the migration, not a decision — the catalogue's ground rule is that nothing is dropped implicitly, and the reference is the list the refactor is measured against.