Skip to content

symfonic.agent.cutover.legacy_pin

legacy_pin

TA8.54 — where a build's legacy pin is built, on the path every build takes.

SymfonicAgent.__init__ calls :func:build_legacy_pin once per agent and hands the result to :class:~symfonic.agent.cutover.switchboard.CutoverSwitchboard, which reads it in route_for. That is the whole wiring, and it is deliberately short: everything about which combinations are documented, and every refusal, lives on the runtime-service side in :mod:symfonic.services.switching.invocation_profile, reached through the declared port symfonic.services.switching.ports.

What a pin means, per switch. One pin name per capability, and the name is the switch's own, so the answer is the switch's:

=============================== ======================================= invocation.run SymfonicAgent.run serves on _legacy_run_impl. invocation.stream SymfonicAgent.stream serves on _stream_impl — which on a line that publishes the text-delta contract means the LangGraph node mappings 11.0 replaced. invocation.stream_typed SymfonicAgent.stream_typed serves on _stream_typed_impl. invocation.continuation Both SymfonicAgent.resume and SymfonicAgent.resume_interrupt (and so POST /resume/{pause_token}) serve on _legacy_continuation_impl. =============================== =======================================

The fourth row is not an exception this module invented: routes.py gives one switch to two public methods on purpose, and a pin over that switch inherits the sharing rather than splitting it. The condition TA8.36 attached to that sharing — that anything certifying the switch must drive both contracts — applies here too, and tests/agent/cutover/test_legacy_pin_drives_the_routes.py drives both.

Pinning one moves one. The four switches are consulted at four independent dispatch sites, so a pin naming one capability leaves the other three exactly where the filed criteria put them. That is asserted per capability rather than in aggregate, because "three of four moved" is the error this programme has already had to correct twice in the probe.

A pin is per build, and does not travel. A sub-agent is constructed by the adopter and passed in (SymfonicAgent(sub_agents=[...])), so a child agent carries the pin its own constructor was given and no other. Nothing here propagates a parent's pin, and nothing here reads an environment variable or a file: like the switchboard it feeds, the pin is a function of what the process was built with.

LegacyPin dataclass

LegacyPin(capabilities: frozenset[str] = frozenset(), package_version: str = '', vector_hash: str = GenerationVector().vector_hash, source: str = 'release-static', binding: str = 'binding[static,unbuilt] (no profile was constructed)')

Which capabilities a build holds on their legacy bodies, and on whose say.

source is StaticBindingSource's own CUT-PIN-3 vocabulary: release-static when the line's published contract is what put a capability on legacy, local-override when the adopter pinned it. The two are not interchangeable and an operator reading describe() at 03:00 is owed the difference.

build_legacy_pin

build_legacy_pin(capabilities: Sequence[str] | None = None, *, package_version: str | None = None) -> LegacyPin

Construct this build's release profile and binding source, and read the pin.

capabilities is None for "this build sets no pin" — the ordinary case, and still a call: the profile and the source are constructed either way, so the construction is on the path every deployment takes rather than on one an adopter has to opt into.

An unrecognised capability is refused before the vector is built, and it is refused by switch_for, which already distinguishes the three ways a name can be wrong: a switch, a coverage register (migrated, verified, and read by no dispatch — so there is no route to pin), or a name that is neither. That distinction is worth more than a flat "unknown capability", and it is the one an operator typing legacy_pin=["memory"] needs.

The shape is refused here too, and separately. A str is a Sequence[str], so legacy_pin="invocation.run" — the obvious typo for the documented list form — would otherwise iterate into single characters and be refused as unknown cutover capability 'i': a real refusal, but one naming a character the operator never typed. In the single mechanism whose stated purpose is refused by name, the name has to be the one that was actually written.

Source code in src/symfonic/agent/cutover/legacy_pin.py
def build_legacy_pin(
    capabilities: Sequence[str] | None = None,
    *,
    package_version: str | None = None,
) -> LegacyPin:
    """Construct this build's release profile and binding source, and read the pin.

    ``capabilities`` is ``None`` for "this build sets no pin" — the ordinary
    case, and still a call: the profile and the source are constructed either
    way, so the construction is on the path every deployment takes rather than
    on one an adopter has to opt into.

    An unrecognised capability is refused before the vector is built, and it is
    refused by ``switch_for``, which already distinguishes the three ways a name
    can be wrong: a switch, a coverage register (migrated, verified, and read by
    no dispatch — so there is no route to pin), or a name that is neither. That
    distinction is worth more than a flat "unknown capability", and it is the
    one an operator typing ``legacy_pin=["memory"]`` needs.

    The *shape* is refused here too, and separately. A ``str`` is a
    ``Sequence[str]``, so ``legacy_pin="invocation.run"`` — the obvious typo for
    the documented list form — would otherwise iterate into single characters
    and be refused as ``unknown cutover capability 'i'``: a real refusal, but
    one naming a character the operator never typed. In the single mechanism
    whose stated purpose is *refused by name*, the name has to be the one that
    was actually written.
    """
    if isinstance(capabilities, str):
        raise ConfigurationError(
            "the legacy pin must be a sequence of capability names, not the "
            f"bare string {capabilities!r}. A string is itself a sequence of "
            "characters, so honouring it would pin one character at a time; "
            f"pass [{capabilities!r}] to name one capability."
        )
    if capabilities is not None:
        for capability in capabilities:
            # Non-strings fall through to ``legacy_pin_vector``, which refuses
            # them by repr. ``switch_for`` would raise ``TypeError`` on an
            # unhashable entry, and a ``TypeError`` is not a refusal by name.
            if isinstance(capability, str):
                switch_for(capability)
    return resolve_legacy_pin(
        None if capabilities is None else tuple(capabilities),
        package_version=package_version or build_version(),
    )

build_version

build_version() -> str

The release line this process is, normalised to its numeric segments.

Read through the module attribute rather than a from-import binding so that the seam tests/agent/cutover/parity_harness.release_line patches — the same one stream()'s own dispatch reads — moves this too. A pin mechanism that disagreed with the dispatch about which line it was on would be answering a question about a different install.

Source code in src/symfonic/agent/cutover/legacy_pin.py
def build_version() -> str:
    """The release line this process is, normalised to its numeric segments.

    Read through the module attribute rather than a ``from``-import binding so
    that the seam ``tests/agent/cutover/parity_harness.release_line`` patches —
    the same one ``stream()``'s own dispatch reads — moves this too. A pin
    mechanism that disagreed with the dispatch about which line it was on would
    be answering a question about a different install.
    """
    return stream_contract.numeric_release_line(stream_contract.installed_release_line())