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_revisionat all. Declaringoperator_editable=Trueagainst 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, ignoringscope, 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.
BlockSourcedeclares the non-method membersoffline_safeandscope_aware, which makes all three of these data Protocols, andissubclass()against a data Protocol raisesTypeErrorrather than returningFalse. Validation must therefore hold a constructed source and useisinstance; 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 answeringisinstancewith a coroutine returningNone. 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--Falseforfeits authored-spine survival when the backing system is unreachable.scope_aware--Falsemeans 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
¶
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
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
¶
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
load_revision
abstractmethod
async
¶
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
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
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
|
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 |
required |
message
|
str | None
|
Why, or |
required |
expected_head
|
str | None
|
The revision the caller believes is current,
or |
required |
Returns:
| Type | Description |
|---|---|
BlockRevision
|
The newly appended :class: |
Raises:
| Type | Description |
|---|---|
RevisionConflictError
|
|
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
block_isolation_key ¶
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
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.