Skip to content

symfonic.core.prompt.blocks.scopeless

scopeless

The block lane for a turn that carries no tenant scope.

scope defaults to None on the public run() / stream() entry points, so the scope-less turn is the ordinary call shape for a CLI, a single-tenant deployment and every example in this repository -- not an edge case to be handled once and forgotten.

Two facts have to be held at once, and this module exists because collapsing either one into the other produces a defect:

  • Scope-keyed content must not be resolved against a fabricated scope. A tenant-keyed source handed a synthetic scope either serves nothing or, worse, serves one tenant's standing context under a scope path no operator configured. Refusing is right.
  • Deployment-global content needs no scope at all. A scope="deployment" block on a scope-unaware source -- a :class:~symfonic.core.prompt.blocks.sources.static.StaticBlockSource literal, a :class:~symfonic.core.prompt.blocks.sources.file.FileBlockSource path -- serves one value for the whole install by declaration, checked at construction by :func:~symfonic.core.prompt.blocks.validation.check_scope_pairing. There is nothing for a scope to key. Refusing that is not caution, it is a false positive that deletes an authored platform-tier BOUNDARIES block from the system prompt of the most common deployment shape there is.

So the scope-less turn partitions its declared specs rather than accepting or rejecting them wholesale: :func:partition_by_scope_requirement. The deployment-global half renders normally; the scope-keyed half is unresolvable, and what happens next is the operator's own on_source_failure declaration -- fail_closed stops the turn, omit / last_known_good degrade as declared. That is the same policy the resolver applies to a source outage, applied to the one failure the resolver cannot see because it happens before resolution.

The reserved scope. Every :class:~symfonic.core.prompt.blocks.protocol.BlockSource method takes (scope, block_id) by signature -- deliberately, so tenant isolation cannot be forgotten by omission -- so the deployment-global half still needs a scope object to pass. :data:DEPLOYMENT_GLOBAL_SCOPE is it. It reaches only sources that declared scope_aware = False, i.e. sources that have said in their own type that they ignore it, so it cannot select content. What it does do is key the render memo and the last_known_good cache, and its kind is reserved precisely so those keys cannot collide with a real tenant's.

DEPLOYMENT_GLOBAL_SCOPE module-attribute

DEPLOYMENT_GLOBAL_SCOPE: Final = TenantScope.root(
    DEPLOYMENT_GLOBAL_SCOPE_KIND, "global"
)

The scope handed to sources that declared they ignore it.

Module-level and frozen: one identity for the whole process means the render memo has one slot for the scope-less lane instead of one per turn.

DEPLOYMENT_GLOBAL_SCOPE_KIND module-attribute

DEPLOYMENT_GLOBAL_SCOPE_KIND: Final = (
    "__symfonic_deployment__"
)

Reserved level kind for :data:DEPLOYMENT_GLOBAL_SCOPE.

Dunder-fenced and namespaced so an adopter's own level kinds -- org, tenant, workspace -- cannot produce this scope_path and share a memo slot or a last_known_good entry with the scope-less lane.

describe_omitted

describe_omitted(specs: Sequence[PromptBlockSpec]) -> str

Name the blocks a scope-less turn is dropping, for the warning.

Source code in src/symfonic/core/prompt/blocks/scopeless.py
def describe_omitted(specs: Sequence[PromptBlockSpec]) -> str:
    """Name the blocks a scope-less turn is dropping, for the warning."""
    return ", ".join(sorted(spec.name for spec in specs))

needs_a_scope

needs_a_scope(spec: PromptBlockSpec) -> bool

Return whether spec is unresolvable without a tenant scope.

Three independent reasons, any one of which is disqualifying:

  • spec.scope != "deployment" -- the operator declared the content per-tenant. There is no tenant here.
  • the source is scope-aware -- it keys on scope.scope_path (a database adapter, a computed callable) or is the memory lane, which reads the tenant's own graph. Reading through :func:~symfonic.core.prompt.blocks.validation.is_scope_aware keeps "undeclared is not a guarantee" true here too: a source that declares nothing is treated as deployment-global, exactly as check_scope_pairing already treats it at construction.
  • spec.render_when is set -- the gate is called as render_when(scope, block_id) and decides per scope. With no scope there is no question to ask it, and calling it with the reserved scope would invent an answer.
Source code in src/symfonic/core/prompt/blocks/scopeless.py
def needs_a_scope(spec: PromptBlockSpec) -> bool:
    """Return whether ``spec`` is unresolvable without a tenant scope.

    Three independent reasons, any one of which is disqualifying:

    * ``spec.scope != "deployment"`` -- the operator declared the
      content per-tenant. There is no tenant here.
    * the source is scope-aware -- it keys on ``scope.scope_path``
      (a database adapter, a computed callable) or *is* the memory lane,
      which reads the tenant's own graph. Reading through
      :func:`~symfonic.core.prompt.blocks.validation.is_scope_aware`
      keeps "undeclared is not a guarantee" true here too: a source that
      declares nothing is treated as deployment-global, exactly as
      ``check_scope_pairing`` already treats it at construction.
    * ``spec.render_when`` is set -- the gate is called as
      ``render_when(scope, block_id)`` and decides *per scope*. With no
      scope there is no question to ask it, and calling it with the
      reserved scope would invent an answer.
    """
    if spec.scope != "deployment":
        return True
    if is_scope_aware(spec.source):
        return True
    return spec.render_when is not None

partition_by_scope_requirement

partition_by_scope_requirement(
    specs: Iterable[PromptBlockSpec],
) -> tuple[
    tuple[PromptBlockSpec, ...], tuple[PromptBlockSpec, ...]
]

Split specs into (deployment_global, scope_required).

Order within each half is the input order; the resolver imposes its own deterministic order later.

Source code in src/symfonic/core/prompt/blocks/scopeless.py
def partition_by_scope_requirement(
    specs: Iterable[PromptBlockSpec],
) -> tuple[tuple[PromptBlockSpec, ...], tuple[PromptBlockSpec, ...]]:
    """Split ``specs`` into ``(deployment_global, scope_required)``.

    Order within each half is the input order; the resolver imposes its
    own deterministic order later.
    """
    deployment_global: list[PromptBlockSpec] = []
    scope_required: list[PromptBlockSpec] = []
    for spec in specs:
        (scope_required if needs_a_scope(spec) else deployment_global).append(spec)
    return tuple(deployment_global), tuple(scope_required)

render_deployment_global async

render_deployment_global(
    injector: PromptBlockInjector,
) -> str | None

Render the declared blocks that need no scope, or None.

None -- the value that leaves every parts list byte-identical -- when nothing survives the partition, which is the whole answer for a deployment whose blocks are all tenant-keyed.

Inside a delegated run this freezes through the same run-local snapshot the scoped lane uses, and for the same two reasons (:mod:~symfonic.core.prompt.blocks.snapshot): a child that re-resolves per turn can contradict itself mid-run when a source moves underneath it, and a body that moves invalidates the cached L0 + L1 prefix the delegation is billed on. "Deployment-global" constrains what a value may be keyed on, not how often it may change -- a FileBlockSource path is rewritten by an operator, a third-party adapter re-reads a config map -- so the scope-less lane needs the freeze exactly as much as the scoped one. Without it a delegated child read boundary-v1 on its first turn and boundary-v2 on its second, with no event in the transcript.

The snapshot is captured under :data:DEPLOYMENT_GLOBAL_SCOPE and narrowed to specs, so it carries the same content this lane would have rendered per turn; the reserved scope_path keeps it from matching -- or being matched by -- any real tenant's snapshot in the same slot. Outside a delegated run ensure_run_snapshot returns None and the per-turn build below runs unchanged, which is right for a top-level turn: the user is the thing changing.

Source code in src/symfonic/core/prompt/blocks/scopeless.py
async def render_deployment_global(injector: PromptBlockInjector) -> str | None:
    """Render the declared blocks that need no scope, or ``None``.

    ``None`` -- the value that leaves every parts list byte-identical --
    when nothing survives the partition, which is the whole answer for a
    deployment whose blocks are all tenant-keyed.

    Inside a delegated run this freezes through the same run-local
    snapshot the scoped lane uses, and for the same two reasons
    (:mod:`~symfonic.core.prompt.blocks.snapshot`): a child that
    re-resolves per turn can contradict itself mid-run when a source
    moves underneath it, and a body that moves invalidates the cached
    ``L0 + L1`` prefix the delegation is billed on. "Deployment-global"
    constrains what a value may be *keyed* on, not how often it may
    change -- a ``FileBlockSource`` path is rewritten by an operator, a
    third-party adapter re-reads a config map -- so the scope-less lane
    needs the freeze exactly as much as the scoped one. Without it a
    delegated child read ``boundary-v1`` on its first turn and
    ``boundary-v2`` on its second, with no event in the transcript.

    The snapshot is captured under :data:`DEPLOYMENT_GLOBAL_SCOPE` and
    narrowed to ``specs``, so it carries the same content this lane
    would have rendered per turn; the reserved ``scope_path`` keeps it
    from matching -- or being matched by -- any real tenant's snapshot
    in the same slot. Outside a delegated run ``ensure_run_snapshot``
    returns ``None`` and the per-turn build below runs unchanged, which
    is right for a top-level turn: the user is the thing changing.
    """
    specs = renderable_without_scope(injector.resolver.specs)
    if not specs:
        return None
    snapshot = await ensure_run_snapshot(
        injector.resolver,
        DEPLOYMENT_GLOBAL_SCOPE,
        policy=injector.policy,
        now=injector.now(),
        specs=specs,
    )
    if snapshot is not None:
        return snapshot.l1
    parts = await injector.build(DEPLOYMENT_GLOBAL_SCOPE, specs=specs)
    return parts.l1

renderable_without_scope

renderable_without_scope(
    specs: Iterable[PromptBlockSpec],
) -> tuple[PromptBlockSpec, ...]

Return the specs a scope-less turn may actually render.

Visible here by :func:specs_visible_here -- which applies inherit=False inside a delegated run, since this lane bypasses the one other place that applies it (:func:~symfonic.core.prompt.blocks.snapshot.inheritable_blocks) -- and deployment-global by :func:needs_a_scope.

Source code in src/symfonic/core/prompt/blocks/scopeless.py
def renderable_without_scope(
    specs: Iterable[PromptBlockSpec],
) -> tuple[PromptBlockSpec, ...]:
    """Return the specs a scope-less turn may actually render.

    Visible here by :func:`specs_visible_here` -- which applies
    ``inherit=False`` inside a delegated run, since this lane bypasses
    the one other place that applies it
    (:func:`~symfonic.core.prompt.blocks.snapshot.inheritable_blocks`)
    -- and deployment-global by :func:`needs_a_scope`.
    """
    deployment_global, _ = partition_by_scope_requirement(specs_visible_here(specs))
    return deployment_global

specs_visible_here

specs_visible_here(
    specs: Iterable[PromptBlockSpec],
) -> tuple[PromptBlockSpec, ...]

Return the specs this scope-less turn may consider at all.

Inside a delegated run, that is the inheritable ones: inherit=False means "not visible to a delegated child", and a block the child cannot see is not a block the child can be held to.

This is the first question the lane asks, before :func:unresolvable_fail_closed and before the partition, and the order is the whole point. Asking it last -- which is what :func:renderable_without_scope alone amounted to -- filters the block out of the render while still letting it vote on whether the turn happens: a scope-keyed fail_closed inherit=False block (canonical ONBOARDING is exactly that shape) aborted every delegated scope-less run with a SecurityScopeError naming a block that, by its own declaration, was never going to be in that child's prompt. The parent's own turn is unaffected -- no snapshot slot is open there, so nothing is filtered and the same block still stops the parent, which is the outcome fail_closed was declared for.

inherit is read through getattr for the reason :func:~symfonic.core.prompt.blocks.snapshot.inheritable_blocks reads it that way: a spec-shaped stand-in need not carry the field, and the safe default for a declared block is that the child gets it.

Source code in src/symfonic/core/prompt/blocks/scopeless.py
def specs_visible_here(
    specs: Iterable[PromptBlockSpec],
) -> tuple[PromptBlockSpec, ...]:
    """Return the specs this scope-less turn may consider **at all**.

    Inside a delegated run, that is the inheritable ones:
    ``inherit=False`` means "not visible to a delegated child", and a
    block the child cannot see is not a block the child can be held to.

    This is the *first* question the lane asks, before
    :func:`unresolvable_fail_closed` and before the partition, and the
    order is the whole point. Asking it last -- which is what
    :func:`renderable_without_scope` alone amounted to -- filters the
    block out of the *render* while still letting it vote on whether the
    turn happens: a scope-keyed ``fail_closed`` ``inherit=False`` block
    (canonical ONBOARDING is exactly that shape) aborted every delegated
    scope-less run with a ``SecurityScopeError`` naming a block that,
    by its own declaration, was never going to be in that child's
    prompt. The parent's own turn is unaffected -- no snapshot slot is
    open there, so nothing is filtered and the same block still stops
    the parent, which is the outcome ``fail_closed`` was declared for.

    ``inherit`` is read through ``getattr`` for the reason
    :func:`~symfonic.core.prompt.blocks.snapshot.inheritable_blocks`
    reads it that way: a spec-shaped stand-in need not carry the field,
    and the safe default for a declared block is that the child gets it.
    """
    if not in_run_snapshot_scope():
        return tuple(specs)
    return tuple(spec for spec in specs if getattr(spec, "inherit", True))

unresolvable_fail_closed

unresolvable_fail_closed(
    specs: Iterable[PromptBlockSpec],
) -> list[str]

Return the names of specs that need a scope and fail closed.

Sorted, because the name goes into an error message an operator has to act on and a set's iteration order would make that message churn.

Source code in src/symfonic/core/prompt/blocks/scopeless.py
def unresolvable_fail_closed(specs: Iterable[PromptBlockSpec]) -> list[str]:
    """Return the names of ``specs`` that need a scope and fail closed.

    Sorted, because the name goes into an error message an operator has
    to act on and a set's iteration order would make that message churn.
    """
    return sorted(
        spec.name
        for spec in specs
        if needs_a_scope(spec) and spec.on_source_failure == "fail_closed"
    )