Skip to content

symfonic.capabilities.human.consumption

consumption

Single-winner consumption (SEC-PTK-3, LIB-TL-1..3).

Two backings, one contract. Neither of them reads before it writes: the whole property being bought is that concurrent redeemers of one jti are decided by a single atomic operation, and a get followed by a put is two operations with a race between them — which is the shape LIB-TL-2 spells out as FORBIDDEN.

Library mode reaches the adopter's persistence backend through :class:ConditionalWriteConsumption; the operated platform reaches its authoritative ledger, which satisfies the same port. Nothing above this module branches on which one it got.

ConditionalWriteConsumption

ConditionalWriteConsumption(backend: Any, *, durable: bool | None = None, clock: Callable[[], float] = time.time)

LIB-TL-2 — one atomic conditional write on the adopter's backend.

The port has exactly one verb because a port that also offered a read would invite the read-then-write sequence the contract forbids. claim is a single await: there is no branch in this method that could become two round-trips later.

Source code in src/symfonic/capabilities/human/consumption.py
def __init__(
    self,
    backend: Any,
    *,
    durable: bool | None = None,
    clock: Callable[[], float] = time.time,
) -> None:
    if not callable(getattr(backend, "insert_if_absent", None)):
        raise InteractionConfigurationError(
            f"{type(backend).__name__} does not offer insert_if_absent(key, "
            "record); single-use consumption needs one atomic conditional "
            "write, and a read-then-write sequence cannot provide it"
        )
    self._backend = backend
    self._clock = clock
    # A backend that does not say, and an adopter who does not say either,
    # is treated as volatile — see :func:`declared_durability`.
    self._durable = declared_durability(backend) if durable is None else durable

ConsumptionRecord dataclass

ConsumptionRecord(jti: str, scope_hash: str, name: str = ASK_USER, consumed_at: float = 0.0)

What a redemption writes. Ids only — no payload, no answer.

InMemoryConsumption

InMemoryConsumption(*, clock: Callable[[], float] = time.time)

The in-process reference implementation (LIB-TL-2's single-lock case).

Correct within one process and honest about the rest: durable is False, and :func:require_durable_consumption is what stops a deployment with durable checkpoints from quietly using it.

Source code in src/symfonic/capabilities/human/consumption.py
def __init__(self, *, clock: Callable[[], float] = time.time) -> None:
    self._consumed: dict[str, ConsumptionRecord] = {}
    self._lock = asyncio.Lock()
    self._clock = clock

clear

clear() -> None

Reset the set — a fixture helper, not a production verb.

Source code in src/symfonic/capabilities/human/consumption.py
def clear(self) -> None:
    """Reset the set — a fixture helper, not a production verb."""
    self._consumed.clear()

declared_durability

declared_durability(backend: Any) -> bool

What a backend says about surviving a restart, read one way everywhere.

Two ports in this package spell the same fact differently — durable is an attribute on an adopter's persistence backend and a method on the checkpoint command port — and an adopter who wires one object into both should not get "volatile" from one of them because of the parentheses. Silence still means volatile: assuming durability nobody asserted is how SEC-PTK-7's honesty rule gets lost quietly, and a wrong guess in the other direction only produces a refusal an operator can read.

Source code in src/symfonic/capabilities/human/consumption.py
def declared_durability(backend: Any) -> bool:
    """What a backend *says* about surviving a restart, read one way everywhere.

    Two ports in this package spell the same fact differently — ``durable`` is an
    attribute on an adopter's persistence backend and a method on the checkpoint
    command port — and an adopter who wires one object into both should not get
    "volatile" from one of them because of the parentheses. Silence still means
    volatile: assuming durability nobody asserted is how SEC-PTK-7's honesty rule
    gets lost quietly, and a wrong guess in the other direction only produces a
    refusal an operator can read.
    """
    declared = getattr(backend, "durable", False)
    if callable(declared):
        try:
            declared = declared()
        except TypeError:  # pragma: no cover - a durable() that needs arguments
            return False
    return declared is True

require_durable_consumption

require_durable_consumption(consumption: Any, *, checkpoints_durable: bool) -> None

SEC-PTK-7 — durable checkpoints must not get a volatile jti store.

A deployment whose checkpoints survive a restart and whose consumed set does not has single-use enforcement only until the next deploy: every token minted before the restart becomes redeemable a second time, and nothing in the logs says so.

A fully volatile deployment is supported, not an error — that is the developer laptop, and its tokens die with the process anyway.

Source code in src/symfonic/capabilities/human/consumption.py
def require_durable_consumption(consumption: Any, *, checkpoints_durable: bool) -> None:
    """SEC-PTK-7 — durable checkpoints must not get a volatile jti store.

    A deployment whose checkpoints survive a restart and whose consumed set does
    not has single-use enforcement only until the next deploy: every token
    minted before the restart becomes redeemable a second time, and nothing in
    the logs says so.

    A fully volatile deployment is supported, not an error — that is the
    developer laptop, and its tokens die with the process anyway.
    """
    if checkpoints_durable and not declared_durability(consumption):
        raise ConsumptionDurabilityError(
            "this deployment has durable checkpoints and a volatile pause-token "
            "consumption store, so single use would not survive a restart. Back "
            "consumption with the same persistence backend as the checkpointer, "
            "or state explicitly that this deployment does not need it"
        )