Skip to content

symfonic.core.prompt.blocks.protocol

protocol

Block source capability contracts -- structural, not self-reported.

A prompt block is served by a source: a file, a config literal, a git repository, a database table, a secrets store. Three Protocols describe what a source can do, in strictly widening capability:

  • :class:BlockSource -- can read the current revision of a block.
  • :class:HistoryCapableBlockSource -- can also list and retrieve prior revisions.
  • :class:WritableBlockSource -- can also append a new revision.

Each is @runtime_checkable, and validation asks isinstance(source, HistoryCapableBlockSource) rather than reading a supports_history boolean off the adapter. The capability is therefore structural: an adapter for a backing store with no history (a plain file, Consul KV, Vault KV v1) simply cannot present the methods, so it cannot claim the capability. There is no flag for it to get wrong.

What isinstance catches, and what it does not

isinstance on a runtime_checkable Protocol verifies member presence only. It checks neither signature nor behaviour.

  • Caught: a source that never implemented list_revisions / load_revision / append_revision at all. Declaring operator_editable=True against it is a construction-time error.
  • Not caught: a source that defines the methods and implements them badly -- returning an empty list, ignoring block_id, ignoring scope, or fabricating revisions. Only that adapter's own tests can catch that.

This is stated plainly rather than buried, because a check that implies more assurance than it delivers is worse than one whose limits are known.

Two further limits are worth naming:

  • The check is instance-only. BlockSource declares the non-method members offline_safe and scope_aware, which makes all three of these data Protocols, and issubclass() against a data Protocol raises TypeError rather than returning False. Validation must therefore hold a constructed source and use isinstance; a registry that wants to check a class before instantiating it cannot.
  • Explicit inheritance is checked at construction, not by presence. Every protocol method is @abstractmethod, so a source that inherits one of these Protocols and omits a method cannot be instantiated at all -- it does not slip through by inheriting an empty ... body and answering isinstance with a coroutine returning None. Duck-typed sources, which inherit nothing, are unaffected and are still matched structurally.

The isolation key

Every method takes the same (scope, block_id) pair, by signature. There is no overload that omits scope, so tenant isolation cannot be dropped by accident, and no overload that omits block_id, so a source backing several blocks cannot return whichever row the scope happened to match first. A single-block source may ignore block_id, but it must still accept it.

The canonical isolation key is :attr:~symfonic.core.scope.TenantScope.scope_path -- the US-delimited, root-first materialised path already used by ancestor_prefix_paths() for exact-IN matching. Use :func:block_isolation_key to derive it.

Keying on scope.tenant_id alone is wrong and is rejected here by name. tenant_id is only the root level of the path. An org:acme -> brand:widgets scope and an org:acme -> brand:gadgets scope share a tenant_id and would collapse into one bucket: the two brands would read and overwrite each other's block revisions, and history -- which is cumulative -- would silently merge. scope_path keeps every level of the hierarchy in the key, so the org/brand collapse cannot happen.

TenantScope.namespace is deliberately NOT part of this key. namespace is documented on TenantScope as "orthogonal to path", and is a real isolation dimension elsewhere in this codebase (the memory layer's episodic store, the JIT context builder). Two scopes that differ only in namespace -- TenantScope.root("org", "acme", namespace="prod") and the same root with namespace="staging" -- therefore produce the same :func:block_isolation_key. This is a scope of intentional narrowing, not an oversight: the block layer's storage key is one column pair, (scope_path, block_id), already bound into SQL statements and cache dict keys throughout the block sources; widening it to a triple would be a storage-schema change to every adapter that persists a block, not a change to this function alone. An adopter who needs namespace to isolate a block source must express it as a path level -- scope.child("namespace", "prod") -- rather than relying on the namespace field; a single source shared across differently-namespaced-but-same-path scopes is therefore a scope-collapse risk in exactly the same shape as the tenant_id case above, and adopters keying their own external state on namespace must not also point it at a shared block source instance.

BlockSource

Bases: Protocol

Reads the current revision of a prompt block for a scope.

offline_safe and scope_aware are declared members, so isinstance requires them to be present: a source that never decided whether it can be reached offline does not satisfy the Protocol.

  • offline_safe -- False forfeits authored-spine survival when the backing system is unreachable.
  • scope_aware -- False means the source is deployment-global: it serves one value for every tenant. Pairing such a source with a non-deployment block scope is a construction-time error.

isinstance verifies that load and both flags exist. It does not verify that load honours scope or block_id -- a source that was never implemented is caught; a source implemented badly is not.

Both flags are declared as abstract properties, not bare annotations, so the nominal inheritance path is closed the same way the methods are: typing._ProtocolMeta.__instancecheck__ short-circuits on the real-subclass check before ever consulting a Protocol's data members, so a bare annotation is only ever enforced on the duck-typed path -- a class Mine(BlockSource) that implements load but forgets offline_safe would answer isinstance truthfully while mine.offline_safe raised AttributeError. Marking them abstract makes that subclass un-instantiable instead, matching what @abstractmethod already does for load itself. A duck-typed source setting a plain offline_safe = True class attribute is unaffected: ABCMeta clears an inherited abstract name the moment the subclass provides any non-abstract value for it, property or plain attribute alike.

load abstractmethod async

load(scope: TenantScope, block_id: str) -> BlockRevision

Return the current revision of block_id for scope.

scope is required and typed :class:TenantScope: the isolation argument cannot be dropped, and the isolation key is scope.scope_path (see :func:block_isolation_key), never scope.tenant_id alone.

block_id is the block spec's name. A source backing several blocks selects by it instead of returning an arbitrary row for the scope. A single-block source may ignore it, but must accept it.

Source code in src/symfonic/core/prompt/blocks/protocol.py
@abstractmethod
async def load(self, scope: TenantScope, block_id: str) -> BlockRevision:
    """Return the current revision of ``block_id`` for ``scope``.

    ``scope`` is required and typed :class:`TenantScope`: the
    isolation argument cannot be dropped, and the isolation key is
    ``scope.scope_path`` (see :func:`block_isolation_key`), never
    ``scope.tenant_id`` alone.

    ``block_id`` is the block spec's name. A source backing several
    blocks selects by it instead of returning an arbitrary row for
    the scope. A single-block source may ignore it, but must accept
    it.
    """
    ...

HistoryCapableBlockSource

Bases: BlockSource, Protocol

A :class:BlockSource whose prior revisions remain retrievable.

An operator-editable block must be backed by one of these: being able to see what a block used to say, and to identify the revision to go back to, is the whole restore path core requires.

Both methods take the same (scope, block_id) key as :meth:BlockSource.load, by signature. History is cumulative, so a history read that forgot isolation would leak strictly more than a current-value read; there is no overload omitting scope, so the argument cannot be forgotten.

isinstance(src, HistoryCapableBlockSource) is True only when both history methods exist. It catches the source that never implemented them -- the common case, since a store with no versioning cannot present them. It does not catch a source that implements them badly: returning an empty list, ignoring block_id, or fabricating revisions all pass presence checks. Only that adapter's own tests can catch those.

Note that this Protocol carries no write verb. Reading history does not imply permission to change it.

list_revisions abstractmethod async

list_revisions(
    scope: TenantScope, block_id: str
) -> Sequence[BlockRevision]

Return the known revisions of block_id for scope.

Ordering is the adapter's own (a git log, an append-only table). Every returned revision must belong to this scope_path and this block_id.

Source code in src/symfonic/core/prompt/blocks/protocol.py
@abstractmethod
async def list_revisions(
    self, scope: TenantScope, block_id: str
) -> Sequence[BlockRevision]:
    """Return the known revisions of ``block_id`` for ``scope``.

    Ordering is the adapter's own (a git log, an append-only table).
    Every returned revision must belong to this ``scope_path`` and
    this ``block_id``.
    """
    ...

load_revision abstractmethod async

load_revision(
    scope: TenantScope, block_id: str, revision: str
) -> BlockRevision

Return one specific prior revision of block_id.

scope is required for the same reason it is on :meth:BlockSource.load: a revision table keyed only by block_id, with the scope recorded on the current-value row alone, would expose every tenant's history through this method.

Source code in src/symfonic/core/prompt/blocks/protocol.py
@abstractmethod
async def load_revision(
    self, scope: TenantScope, block_id: str, revision: str
) -> BlockRevision:
    """Return one specific prior revision of ``block_id``.

    ``scope`` is required for the same reason it is on
    :meth:`BlockSource.load`: a revision table keyed only by
    ``block_id``, with the scope recorded on the current-value row
    alone, would expose every tenant's history through this method.
    """
    ...

RevisionConflictError

RevisionConflictError(
    *,
    scope_path: str,
    block_id: str,
    expected_head: str | None,
    actual_head: str | None,
)

Bases: ConflictError

An append was attempted against a head the caller no longer holds.

Raised by :meth:WritableBlockSource.append_revision when expected_head does not match the block's current head revision -- another writer appended in between. The write is rejected; it is never applied on top of the newer revision, because doing so would silently discard the concurrent edit while leaving the history looking linear.

It derives from :class:~symfonic.core.protocols.ConflictError (and so from StorageError) deliberately: an optimistic-lock failure is the same concept whether it is detected by :func:ensure_expected_head or by the backing store's own unique constraint. A caller mapping except ConflictError to a 409 must catch both paths, or the pre-check path would surface as a 500.

Source code in src/symfonic/core/prompt/blocks/protocol.py
def __init__(
    self,
    *,
    scope_path: str,
    block_id: str,
    expected_head: str | None,
    actual_head: str | None,
) -> None:
    self.scope_path = scope_path
    self.block_id = block_id
    self.expected_head = expected_head
    self.actual_head = actual_head
    super().__init__(
        f"cannot append to block {block_id!r} at scope_path {scope_path!r}: "
        f"caller expected head {expected_head!r} but the current head is "
        f"{actual_head!r}; the block changed underneath the caller, so the "
        "append is rejected rather than layered on top of an unseen revision"
    )

WritableBlockSource

Bases: HistoryCapableBlockSource, Protocol

A history-capable source core may append new revisions to.

Extends :class:HistoryCapableBlockSource deliberately: anything editable must be able to show its history, so a source that can be written but cannot list what it used to hold is not expressible by this type.

The verb is append, and only append. There is no update, no delete, no rewind and no restore-in-place. Restoring an earlier revision is performed by appending a new revision carrying that revision's content, so history stays strictly additive and the restore is itself an auditable entry rather than an erasure.

isinstance(src, WritableBlockSource) is True only when append_revision exists in addition to both history methods -- writability is structural, not a self-reported flag. As with the other Protocols it catches the source that never implemented the method; it does not catch one that implements it badly (ignoring expected_head, writing outside the scope's scope_path).

This is an operator-facing capability. No block-edit tool is registered in the agent's tool palette; the agent cannot reach it.

append_revision abstractmethod async

append_revision(
    scope: TenantScope,
    block_id: str,
    content: str,
    *,
    author: str | None,
    message: str | None,
    expected_head: str | None,
) -> BlockRevision

Append a new revision of block_id for scope.

Parameters:

Name Type Description Default
scope TenantScope

Required isolation argument; the stored key is scope.scope_path (see :func:block_isolation_key).

required
block_id str

The block spec's name.

required
content str

The new body. Appended as a new revision -- the previous one is retained and remains loadable.

required
author str | None

Who requested the write, or None when the caller genuinely does not know. Keyword-only and required so it is a decision, never a defaulted "system".

required
message str | None

Why, or None. Same rule as author.

required
expected_head str | None

The revision the caller believes is current, or None to assert that no revision exists yet. Keyword-only and required so optimistic concurrency cannot be skipped by omission.

required

Returns:

Type Description
BlockRevision

The newly appended :class:BlockRevision.

Raises:

Type Description
RevisionConflictError

expected_head does not match the current head -- another writer got there first. The append is rejected; it is never silently applied over the newer revision.

Implementation contract -- the head check MUST be atomic with the append: An adapter that calls :func:ensure_expected_head as a pre-check and then writes in a separate step is not conflict-safe: two concurrent callers can both read the same head, both pass the pre-check, and both append, silently superseding one another while the history looks linear -- exactly what this method exists to prevent. :func:ensure_expected_head documents itself as "a fail-fast pre-check only" for this reason. The real guarantee must come from the backing store: a unique constraint on (scope_path, block_id, parent_revision), a conditional write (compare-and-swap), or a transaction that reads the head and appends inside one atomic unit.

Source code in src/symfonic/core/prompt/blocks/protocol.py
@abstractmethod
async def append_revision(
    self,
    scope: TenantScope,
    block_id: str,
    content: str,
    *,
    author: str | None,
    message: str | None,
    expected_head: str | None,
) -> BlockRevision:
    """Append a new revision of ``block_id`` for ``scope``.

    Args:
        scope: Required isolation argument; the stored key is
            ``scope.scope_path`` (see :func:`block_isolation_key`).
        block_id: The block spec's name.
        content: The new body. Appended as a new revision -- the
            previous one is retained and remains loadable.
        author: Who requested the write, or ``None`` when the caller
            genuinely does not know. Keyword-only and required so it
            is a decision, never a defaulted ``"system"``.
        message: Why, or ``None``. Same rule as ``author``.
        expected_head: The revision the caller believes is current,
            or ``None`` to assert that no revision exists yet.
            Keyword-only and required so optimistic concurrency
            cannot be skipped by omission.

    Returns:
        The newly appended :class:`BlockRevision`.

    Raises:
        RevisionConflictError: ``expected_head`` does not match the
            current head -- another writer got there first. The
            append is rejected; it is never silently applied over the
            newer revision.

    Implementation contract -- the head check MUST be atomic with
    the append:
        An adapter that calls :func:`ensure_expected_head` as a
        pre-check and then writes in a separate step is not
        conflict-safe: two concurrent callers can both read the same
        head, both pass the pre-check, and both append, silently
        superseding one another while the history looks linear --
        exactly what this method exists to prevent.
        :func:`ensure_expected_head` documents itself as "a fail-fast
        pre-check only" for this reason. The real guarantee must come
        from the backing store: a unique constraint on
        ``(scope_path, block_id, parent_revision)``, a conditional
        write (compare-and-swap), or a transaction that reads the
        head and appends inside one atomic unit.
    """
    ...

block_isolation_key

block_isolation_key(
    scope: TenantScope, block_id: str
) -> tuple[str, str]

Return the canonical storage key for one block in one scope.

The scope half of the key is :attr:TenantScope.scope_path verbatim -- the full root-first path (org\x1facme\x1fbrand\x1fwidgets), not scope.tenant_id. Keying on tenant_id alone would collapse every brand and conversation under an org into a single bucket, so org:acme/brand:widgets and org:acme/brand:gadgets would share -- and overwrite -- one another's revision history.

A tuple is returned rather than a joined string so that no block_id containing the path delimiter can forge a level boundary and impersonate another scope's key.

Two TenantScope values denoting the same path produce the same key; two denoting different paths never do.

scope.namespace is NOT part of the key -- see the "namespace" section of this module's docstring. Two scopes sharing a scope_path but differing only in namespace produce the same key here, deliberately: this is a two-column storage key already bound into every shipped adapter, and namespace isolation, where needed, is expressed as a path level via scope.child(...).

Source code in src/symfonic/core/prompt/blocks/protocol.py
def block_isolation_key(scope: TenantScope, block_id: str) -> tuple[str, str]:
    """Return the canonical storage key for one block in one scope.

    The scope half of the key is :attr:`TenantScope.scope_path` verbatim
    -- the full root-first path (``org\\x1facme\\x1fbrand\\x1fwidgets``),
    not ``scope.tenant_id``. Keying on ``tenant_id`` alone would collapse
    every brand and conversation under an org into a single bucket, so
    ``org:acme/brand:widgets`` and ``org:acme/brand:gadgets`` would share
    -- and overwrite -- one another's revision history.

    A tuple is returned rather than a joined string so that no
    ``block_id`` containing the path delimiter can forge a level boundary
    and impersonate another scope's key.

    Two ``TenantScope`` values denoting the same path produce the same
    key; two denoting different paths never do.

    ``scope.namespace`` is NOT part of the key -- see the "namespace"
    section of this module's docstring. Two scopes sharing a
    ``scope_path`` but differing only in ``namespace`` produce the same
    key here, deliberately: this is a two-column storage key already
    bound into every shipped adapter, and namespace isolation, where
    needed, is expressed as a path level via ``scope.child(...)``.
    """
    return (scope.scope_path, block_id)

ensure_expected_head

ensure_expected_head(
    scope: TenantScope,
    block_id: str,
    *,
    expected_head: str | None,
    actual_head: str | None,
) -> None

Raise :class:RevisionConflictError unless the heads agree.

The shared optimistic-concurrency check for :meth:WritableBlockSource.append_revision implementations, so every adapter rejects a stale write the same way instead of each inventing its own (or, worse, overwriting). It sits on top of the backing store's own unique-sequence constraint, it does not replace it.

Source code in src/symfonic/core/prompt/blocks/protocol.py
def ensure_expected_head(
    scope: TenantScope,
    block_id: str,
    *,
    expected_head: str | None,
    actual_head: str | None,
) -> None:
    """Raise :class:`RevisionConflictError` unless the heads agree.

    The shared optimistic-concurrency check for
    :meth:`WritableBlockSource.append_revision` implementations, so every
    adapter rejects a stale write the same way instead of each inventing
    its own (or, worse, overwriting). It sits on top of the backing
    store's own unique-sequence constraint, it does not replace it.
    """
    if expected_head != actual_head:
        raise RevisionConflictError(
            scope_path=scope.scope_path,
            block_id=block_id,
            expected_head=expected_head,
            actual_head=actual_head,
        )