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.StaticBlockSourceliteral, a :class:~symfonic.core.prompt.blocks.sources.file.FileBlockSourcepath -- 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
¶
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
¶
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 ¶
Name the blocks a scope-less turn is dropping, for the warning.
needs_a_scope ¶
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_awarekeeps "undeclared is not a guarantee" true here too: a source that declares nothing is treated as deployment-global, exactly ascheck_scope_pairingalready treats it at construction. spec.render_whenis set -- the gate is called asrender_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
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
render_deployment_global
async
¶
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
renderable_without_scope ¶
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
specs_visible_here ¶
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
unresolvable_fail_closed ¶
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.