Skip to content

symfonic.capabilities.memory.contribution

contribution

What memory is allowed to declare to the prompt compiler.

A deliberate narrowing of T3.2.1's prompt contribution contract, not a copy. The prompting capability accepts contributions at any tier because some of them are the operator's own instructions; a recall is content the system inferred from what a user said, so three declarations the general contract permits are refused here — the authored tiers, the L0 cached prefix, and pinning — and the refusal is a constructor check rather than a review note.

The two contracts are kept aligned by vocabulary, not by an import. A capability may import the kernel's contracts and its own package; two capabilities that import each other are one capability wearing two names. This is the same seam T3.2.3's knowledge bridge uses, for the same reason, and tests/capabilities/memory/test_prompting_seam.py holds both halves at once so a vocabulary drift is a red test rather than a runtime ValueError in an adopter's deployment.

The source here is pre-hydrated: retrieval already happened, asynchronously, in the bridge's prompt/input stage, and :meth:HydratedMemorySource.read only projects the materialised result. That is what lets an async memory system feed a synchronous compiler without either one learning about the other — and why this source can honestly declare itself offline-safe.

ContributionLayer

Bases: StrEnum

The stratigraphic layer a recall renders on.

ContributionScope

Bases: StrEnum

How widely one contribution's content is shared.

ContributionTier

Bases: StrEnum

Authority tiers, mirroring the prompt contract's vocabulary.

HydratedMemorySource dataclass

HydratedMemorySource(result: RetrievalResult, scope_path: str, scope_aware: bool = True, offline_safe: bool = True)

A source over an already-hydrated retrieval, bound to the scope it used.

The binding is the security property. A compiled prompt is built from whatever contributions the caller passed; without the check below, a hydration performed for one tenant would render into another tenant's compile if a composition root reused the object — which is exactly the kind of reuse an object pool or a cached request makes easy.

MemoryContextSource

Bases: Protocol

Reads the recall block for one contribution and scope.

The two flags are read-only properties, not settable attributes, and the distinction is not a typing nicety. As bare annotations they described a shape the snapshot refuses at runtime: a non-frozen dataclass satisfying this protocol exactly as written is rejected by canonical_payload with "carries a mutable dataclass". The protocol was inviting an adopter to write a source that type-checks and then dies on its first turn.

Settable flags are also wrong on their own terms here. MemoryContribution.validate() gates on scope_aware, and an input to a gate that can change after validation — on an object that then lives in a shared snapshot — is not an input, it is a suggestion.

@property + @abstractmethod rather than annotations, matching BlockSource in core/prompt/blocks/protocol.py, whose own docstring explains why: a bare annotation leaves the nominal inheritance path unenforced, so a subclass that forgets one still answers isinstance truthfully and raises AttributeError on access.

MemoryContribution dataclass

MemoryContribution(contribution_id: str, source: MemoryContextSource, capability: str = 'memory', layer: ContributionLayer = ContributionLayer.L2, tier: ContributionTier = ContributionTier.SESSION, scope: ContributionScope = ContributionScope.DEPLOYMENT, order: int = 0, inherit: bool = True, pinned: bool = False, requires_hydration: bool = True)

The bridge's declaration of the recall block it contributes.

validate

validate() -> None

Refuse every declaration this capability is not allowed to make.

Source code in src/symfonic/capabilities/memory/contribution.py
def validate(self) -> None:
    """Refuse every declaration this capability is not allowed to make."""
    if not self.contribution_id:
        raise MemoryContractError(
            "a memory contribution must declare a non-empty contribution_id."
        )
    if not _ID_CHARSET.match(self.contribution_id):
        raise MemoryContractError(
            f"contribution id {self.contribution_id!r} is outside the permitted charset "
            "[A-Za-z0-9_.-]; ids appear in isolation keys and in the rendered untrusted "
            "delimiter, where a separator or an angle bracket forges a boundary."
        )
    if not callable(getattr(self.source, "read", None)):
        raise MemoryContractError(
            f"contribution {self.contribution_id!r} declares a source that cannot read: "
            f"{type(self.source).__name__} has no callable read()."
        )
    if self.tier in AUTHORED_TIERS:
        raise MemoryContractError(
            f"contribution {self.contribution_id!r} declares the authored tier "
            f"{self.tier.value!r}. A recall is aggregated from what a user said; an "
            "authored tier renders it verbatim beside the operator's own instructions."
        )
    if self.layer is ContributionLayer.L0:
        raise MemoryContractError(
            f"contribution {self.contribution_id!r} declares layer L0. L0 is the cached "
            "authored prefix: a recall placed there is served back to the model on every "
            "later turn of the session, so one poisoned memory outlives the turn that "
            "retrieved it. Declare L1 (standing) or L2 (per turn)."
        )
    if self.pinned:
        raise MemoryContractError(
            f"contribution {self.contribution_id!r} declares itself pinned. Pinned content "
            "fails the compile rather than being dropped for budget; a recall is context, "
            "not instruction, and a turn without it is still a correct turn."
        )
    if self.scope is not ContributionScope.DEPLOYMENT and not getattr(
        self.source, "scope_aware", False
    ):
        raise MemoryContractError(
            f"contribution {self.contribution_id!r} declares scope={self.scope.value!r} "
            f"but its source {type(self.source).__name__} is not scope_aware: it serves "
            "one value for every tenant."
        )

MemoryRead dataclass

MemoryRead(text: str, revision: str = '', untrusted: bool = True)

What a memory source answered.

untrusted defaults to True — the inverse of the general prompt contract's default, and the whole point of a separate value type. A recall is aggregated from what a user said; a source here would have to remember to declare it trusted, which nothing in this capability ever does.

MemoryRequest dataclass

MemoryRequest(contribution_id: str, scope_path: str = '', turn: int = 0)

What a source is asked for: one contribution, one scope, one turn.

contribution_spec

contribution_spec(contribution: MemoryContribution) -> Mapping[str, object]

Project a validated declaration into compiler-ready keyword values.

A mapping rather than a compiler object: the composition root — which is allowed to see both capabilities — turns this into a PromptContribution, so neither capability can quietly start depending on the other's internals.

Enum members are emitted as their string values; the prompt contract's layers, tiers, and scopes are StrEnums over the same strings, so the root's conversion is total by construction.

Source code in src/symfonic/capabilities/memory/contribution.py
def contribution_spec(contribution: MemoryContribution) -> Mapping[str, object]:
    """Project a validated declaration into compiler-ready keyword values.

    A mapping rather than a compiler object: the composition root — which is
    allowed to see both capabilities — turns this into a ``PromptContribution``,
    so neither capability can quietly start depending on the other's internals.

    Enum members are emitted as their string values; the prompt contract's
    layers, tiers, and scopes are ``StrEnum``s over the same strings, so the
    root's conversion is total by construction.
    """
    contribution.validate()
    return MappingProxyType(
        {
            "contribution_id": contribution.contribution_id,
            "source": contribution.source,
            "capability": contribution.capability,
            "layer": contribution.layer.value,
            "tier": contribution.tier.value,
            "scope": contribution.scope.value,
            "order": contribution.order,
            "inherit": contribution.inherit,
            "pinned": contribution.pinned,
            "requires_hydration": contribution.requires_hydration,
        }
    )

resolve_contribution_scope

resolve_contribution_scope(scope: str | None) -> ContributionScope

Turn a caller's scope string into a member, or refuse it in-hierarchy.

Source code in src/symfonic/capabilities/memory/contribution.py
def resolve_contribution_scope(scope: str | None) -> ContributionScope:
    """Turn a caller's scope string into a member, or refuse it in-hierarchy."""
    if scope is None:
        return ContributionScope.DEPLOYMENT
    try:
        return ContributionScope(scope)
    except ValueError as exc:
        raise MemoryContractError(
            f"scope {scope!r} is not a contribution scope; permitted values are "
            f"{[member.value for member in ContributionScope]}."
        ) from exc