Skip to content

symfonic.kernel.contracts.snapshot

snapshot

Freezing what a caller says about a turn, so it stays what they said.

A governance rule that asks "is this account in the EU?" needs an answer, and the answer is a fact about the deployment rather than about the prompt. Until now there was nowhere to put it: GovernanceSubject.properties existed and was empty at every rung, so the one example that needed it supplied the fact out of band, through an object the rule reached for directly.

Three properties make that safe to fix, and all three are about not being a back channel.

Frozen, not merely typed as a Mapping. Several rungs read the same snapshot in one turn. A stage that mutated it would be editing what a later rung sees, which is a capability the ladder deliberately does not grant: a stage says what it decided by returning a StageResult, not by writing into the turn behind the next stage's back. Freezing is recursive, because a shallow proxy over a dict whose values are dicts is not frozen at all.

Copied at the boundary. The caller's own object is not retained. A caller that mutates the dict it passed after the turn starts changes nothing, so the snapshot cannot disagree with itself halfway through a turn.

Invisible in output. These are the deployment's facts -- an account region, an entitlement, a verification flag -- and they are not the kernel's to print. The field carrying them is excluded from its dataclass repr so a formatted request, a traceback frame and an error message that interpolates the turn all stay clean by construction rather than by everyone remembering.

freeze_properties

freeze_properties(properties: Any) -> Mapping[str, Any]

A recursively immutable copy of what the caller declared.

None and an empty mapping both give :data:EMPTY_PROPERTIES, so a turn that declared nothing carries nothing rather than an empty container of its own.

Nested mappings become proxies and nested sequences become tuples. A value that is neither -- a string, a number, a date, an adopter's own object -- is carried as it is: this function freezes the structure the caller handed over, and cannot make an arbitrary object immutable. An adopter who puts a mutable object in a property has kept a handle on it, which is theirs to reason about; what this guarantees is that the shape a rule reads cannot be rewritten under it by another rule.

Source code in src/symfonic/kernel/contracts/snapshot.py
def freeze_properties(properties: Any) -> Mapping[str, Any]:
    """A recursively immutable copy of what the caller declared.

    ``None`` and an empty mapping both give :data:`EMPTY_PROPERTIES`, so a
    turn that declared nothing carries nothing rather than an empty container
    of its own.

    Nested mappings become proxies and nested sequences become tuples. A value
    that is neither -- a string, a number, a date, an adopter's own object --
    is carried as it is: this function freezes the *structure* the caller
    handed over, and cannot make an arbitrary object immutable. An adopter who
    puts a mutable object in a property has kept a handle on it, which is
    theirs to reason about; what this guarantees is that the shape a rule
    reads cannot be rewritten under it by another rule.
    """
    if not properties:
        return EMPTY_PROPERTIES
    if not isinstance(properties, Mapping):
        raise TypeError(
            "turn properties must be a mapping of names to values, got "
            f"{type(properties).__name__}. They are read by rules as "
            "`subject.properties['user_profile']['region']`-shaped facts."
        )
    return MappingProxyType(
        {str(key): _frozen(value) for key, value in properties.items()}
    )