Skip to content

symfonic.capabilities.prompting.gates

gates

Render gates: what must hold before a contribution's bytes reach the prompt.

Authored content renders verbatim. Learned content renders as data, inside a delimiter wrapper the content itself may not forge, because a profile assembled from what a user typed is attacker-influenced input sitting in the same window as the operator's instructions.

Three neutralisations do the load-bearing work inside the wrapper:

  1. Delimiter lookalikes — anything shaped like the opening or closing tag, matched loosely (any separator between untrusted and data, optional closing bracket, case-insensitive) because </untrusted:data> and ``

GateResult dataclass

GateResult(text: str | None, diagnostics: tuple[PromptDiagnostic, ...] = ())

What a gate decided for one contribution.

RenderPolicy dataclass

RenderPolicy(max_learned_chars: int = 500, render_when: Callable[[PromptContribution], Any] | None = None)

The limits applied to learned content, and the set-wide render gate.

Authored content is deliberately uncapped: it is operator configuration, already reviewed by whoever deployed it, and a cap there would silently delete instructions the operator can see in their own config file.

render_when is S01's counterpart to the legacy per-block predicate, and it lives here rather than on the contribution for the reason PROMPT_BLOCK_CONTRACT published ahead of the port: the kernel gate is a policy over the whole set, so a host predicate migrates as a policy rule. It is consulted before any source is read, so a gated-off contribution costs no I/O -- the same ordering PromptBlockResolver.resolve_block documents. It may return an awaitable, in which case only the asynchronous door can honour it; the synchronous one refuses by name.

neutralise_delimiters

neutralise_delimiters(value: str) -> str

Replace every delimiter-shaped sequence in value.

Two rules, because "looks like the closing delimiter" is a question with a lexical answer and a visual one, and content that defeats either has escaped the wrapper.

Source code in src/symfonic/capabilities/prompting/gates.py
def neutralise_delimiters(value: str) -> str:
    """Replace every delimiter-shaped sequence in ``value``.

    Two rules, because "looks like the closing delimiter" is a question with a
    lexical answer and a visual one, and content that defeats either has
    escaped the wrapper.
    """
    neutralised = _DELIMITER_LOOKALIKE.sub(NEUTRALISED, value)
    return _TAGGISH_SPAN.sub(
        lambda match: match.group(0) if match.group(0).isascii() else NEUTRALISED,
        neutralised,
    )

normalise_learned

normalise_learned(value: str) -> str

Strip control characters, then neutralise delimiter forgeries.

Source code in src/symfonic/capabilities/prompting/gates.py
def normalise_learned(value: str) -> str:
    """Strip control characters, then neutralise delimiter forgeries."""
    return neutralise_delimiters(_CONTROL_CHARS.sub("", value))

render_contribution

render_contribution(contribution: PromptContribution, read: SourceRead, policy: RenderPolicy = DEFAULT_POLICY) -> GateResult

Gate one read and return the text that may render, or None.

Raises only on a trust/tier mismatch: that is the one failure where continuing means putting attacker-influenced text where the model reads operator instruction. Every other refusal drops with a diagnostic.

Source code in src/symfonic/capabilities/prompting/gates.py
def render_contribution(
    contribution: PromptContribution,
    read: SourceRead,
    policy: RenderPolicy = DEFAULT_POLICY,
) -> GateResult:
    """Gate one read and return the text that may render, or ``None``.

    Raises only on a trust/tier mismatch: that is the one failure where
    continuing means putting attacker-influenced text where the model reads
    operator instruction. Every other refusal drops with a diagnostic.
    """
    name = contribution.contribution_id
    if read.untrusted and contribution.tier in {TrustTier.PLATFORM, TrustTier.OPERATING}:
        raise RenderGateError(
            f"contribution {name!r} renders at the authored tier "
            f"{contribution.tier.value!r}, but its source declared the payload untrusted. "
            "Authored tiers render verbatim; move this contribution to a learned tier "
            "('profile' or 'session') so it renders inside the untrusted-data wrapper."
        )

    selected = select_body(contribution, read)
    if selected is None:
        return GateResult(
            None,
            (
                PromptDiagnostic(
                    "gate",
                    name,
                    f"dropped: profile_fields {sorted(contribution.profile_fields)!r} named "
                    "no value this source returned, so the contribution would render its "
                    "prose instead of the fields it declared",
                ),
            ),
        )

    if not contribution.is_learned:
        if not selected.strip():
            return GateResult(None, (PromptDiagnostic("gate", name, "empty; nothing to render"),))
        return GateResult(_prefixed(contribution, selected))

    body = _prefixed(contribution, normalise_learned(selected))
    if not body.strip():
        return GateResult(None, (PromptDiagnostic("gate", name, "empty; nothing to render"),))
    if len(body) > policy.max_learned_chars:
        return GateResult(
            None,
            (
                PromptDiagnostic(
                    "gate",
                    name,
                    f"dropped: {len(body)} chars exceeds the learned-content cap of "
                    f"{policy.max_learned_chars}; learned content is dropped, never truncated",
                ),
            ),
        )
    return GateResult(f"{untrusted_open_tag(name)}\n{body}\n{UNTRUSTED_CLOSE}")

sanitise_name

sanitise_name(name: str) -> str

Reduce a contribution id to what may appear inside the open delimiter.

Source code in src/symfonic/capabilities/prompting/gates.py
def sanitise_name(name: str) -> str:
    """Reduce a contribution id to what may appear inside the open delimiter."""
    return _NAME_CHARSET.sub("", name)

select_body

select_body(contribution: PromptContribution, read: SourceRead) -> str | None

The bytes a contribution offers, after profile_fields selects (S01).

None means the contribution declared fields and the source named none of them. That is a drop rather than a fallback to the prose: falling back would render everything the source knows about a person on the turn an operator narrowed the selection to one field, which is the opposite of what they asked for.

Values render sorted by key so two compiles of one request produce one prompt -- the same totality reason ordering_key gives.

Source code in src/symfonic/capabilities/prompting/gates.py
def select_body(contribution: PromptContribution, read: SourceRead) -> str | None:
    """The bytes a contribution offers, after ``profile_fields`` selects (S01).

    ``None`` means the contribution declared fields and the source named none of
    them. That is a *drop* rather than a fallback to the prose: falling back
    would render everything the source knows about a person on the turn an
    operator narrowed the selection to one field, which is the opposite of what
    they asked for.

    Values render sorted by key so two compiles of one request produce one
    prompt -- the same totality reason ``ordering_key`` gives.
    """
    if not contribution.profile_fields:
        return read.text
    available = read.fields or {}
    chosen = [
        f"{key}: {available[key]}"
        for key in sorted(contribution.profile_fields)
        if key in available
    ]
    return "\n".join(chosen) if chosen else None

untrusted_open_tag

untrusted_open_tag(contribution_id: str) -> str

The opening delimiter for contribution_id.

Source code in src/symfonic/capabilities/prompting/gates.py
def untrusted_open_tag(contribution_id: str) -> str:
    """The opening delimiter for ``contribution_id``."""
    return f'<{UNTRUSTED_TAG} source="{sanitise_name(contribution_id)}">'