Skip to content

Types Module — Run Model Vocabulary

types

Public types directory — shared vocabulary for functualize users.

This module re-exports frozen dataclasses and enums that form the shared type vocabulary for job authors, plugin authors, and app constructors.

Usage::

from functualize.types import JobResult, JobDescriptor, RunStatus

GLOBAL_BOOL_FLAGS = frozenset({'--no-dotenv', '--prompt-gates', '--no-prompt-gates', '--force', '--help', '-h'}) module-attribute

GLOBAL_OPTIONS_ALWAYS_VALUE = frozenset({'--log-level', '--dotenv-file', '--config-directory', '--discovery-depth', '--require-file-import', '--require-file-prefix', '--require-file-postfix', '--require-file-marker', '--require-job-prefix', '--require-job-postfix', '--require-job-decorators', '--exclude', '--perf-filter', '--import-libs'}) module-attribute

GLOBAL_OPTIONS_OPTIONAL_VALUE = frozenset({'--perf-report', '--emit-format'}) module-attribute

GLOBAL_OPTIONS_WITH_VALUE = GLOBAL_OPTIONS_ALWAYS_VALUE | GLOBAL_OPTIONS_OPTIONAL_VALUE module-attribute

OPTIONAL_VALUE_VALID_SET = {'--perf-report': (frozenset({'text', 'json'}), 'text'), '--emit-format': (frozenset({'auto', 'json', 'ndjson', 'raw', 'none'}), 'auto')} module-attribute

RUN_SURFACES = frozenset(get_args(RunSurface)) module-attribute

Every legal value of :attr:RunRequest.surface.

Derived from the Literal rather than repeated, so the two cannot drift.

RunSurface = Literal['func.job', 'func.group', 'func.single-file', 'app.cli', 'app.execute', 'func.builtin', 'tui.inline', 'tui.shell', 'mcp.tool', 'mcp.run-job', 'mcp.async', 'http', 'lambda', 'invoke', 'invoke.parallel', 'app.parallel', 'event.job-submit', 'engine.step', 'engine.dependency'] module-attribute

__all__ = ['ExitCode', 'exit_code_for_status', 'Family', 'is_failure', 'report_line', 'status_from_wire', 'wire_value', 'MissingValueError', 'RUN_SURFACES', 'RunRequest', 'request_from_envelope', 'RunSurface', 'JobResult', 'JobDescriptor', 'FieldDescriptor', 'RunStatus', 'RunType', 'JobPhase', 'CacheInfo', 'ConfigFileInfo', 'ConfigFileRole', 'EnvironmentSource', 'Secret', 'http_status_for_status', 'GLOBAL_OPTIONS_ALWAYS_VALUE', 'GLOBAL_OPTIONS_OPTIONAL_VALUE', 'OPTIONAL_VALUE_VALID_SET', 'GLOBAL_OPTIONS_WITH_VALUE', 'GLOBAL_BOOL_FLAGS', 'flag_aliases', 'negative_aliases', 'match_group_flag', 'negative_flag_for'] module-attribute

MissingValueError(field, env_var, *, hint='')

Bases: Exception

A required value was absent and could not be collected.

Carries the field and env var rather than only a message so a caller (or a test) can act on the parts without parsing prose.

Source code in src/functualize/_engine/missing_value.py
def __init__(self, field: str, env_var: str, *, hint: str = "") -> None:
    self.field = field
    self.env_var = env_var
    detail = f" {hint}" if hint else ""
    super().__init__(
        f"Missing required value {field!r}. "
        f"Set it with the {env_var} environment variable, in your config "
        f"file, or run interactively to be prompted.{detail}"
    )

field = field instance-attribute

env_var = env_var instance-attribute

CacheInfo(entry_count, stale_count, file_size_bytes, cache_path) dataclass

Statistics about the job discovery cache.

Attributes:

Name Type Description
entry_count int

Number of entries currently in the cache.

stale_count int

Number of entries that are stale (source changed).

file_size_bytes int

Size of the cache file in bytes.

cache_path Path | None

Path to the cache file, or None if no cache exists.

entry_count instance-attribute

stale_count instance-attribute

file_size_bytes instance-attribute

cache_path instance-attribute

ConfigFileInfo(path, environment_slot, role, precedence, values=dict(), parsed=True) dataclass

A discovered config file and the part it plays in resolution.

Answers, for one file: where is it, which environment slot does it name, is it actually contributing under the active environment, and what did it contribute. Delivery layers need all of that together — knowing a file merely exists is not enough to explain why its values aren't winning.

Attributes:

Name Type Description
path str

Absolute path to the file.

environment_slot str | None

The <slot> in config.<slot>.<ext>, or None for an unslotted config.<ext>.

role ConfigFileRole

Whether this file is merged always (BASE), merged on top for the active environment (OVERLAY), or belongs to a different environment and is never merged (INERT).

precedence int | None

Merge rank among contributing files — lower wins. None for INERT files, which never merge and so have no rank.

values dict[str, Any]

The file's own parsed contents (not the merged view). Empty when the file could not be parsed.

parsed bool

False when no FormatProvider matched the extension, or the file could not be read — the file is reported, not silently dropped, so a typo'd extension is diagnosable.

path instance-attribute

environment_slot instance-attribute

role instance-attribute

precedence instance-attribute

values = field(default_factory=dict) class-attribute instance-attribute

parsed = True class-attribute instance-attribute

is_active property

True if this file contributes to the resolved configuration.

FieldDescriptor(name, type_annotation, default, description, required, choices=None, positional=False, short_flag=None, is_stdin=False, stdin_flag=None, secret=False, from_config_model=False) dataclass

Structured parameter schema for a job's configuration field.

Attributes:

Name Type Description
name str

Field name.

type_annotation str

Type string (e.g., "str", "int", "bool", "list[str]").

default Any | None

Default value, or None if required.

description str

Field description text.

required bool

True if the field has no default value.

choices list[str] | None

Enum member values (non-empty list if type is enum, None otherwise).

positional bool

True if marked with Arg() — a positional CLI argument.

short_flag str | None

Short flag alias (e.g., "-t") from Option() marker, or None.

is_stdin bool

True if marked with Stdin() — reads from a pipe when available.

stdin_flag str | None

Explicit flag name from Stdin(flag=...), or None to derive from the field name.

secret bool

True when the field is marked secret by the model — either the Secret annotation or Field(json_schema_extra={"secret": True}). Carried here, rather than re-derived, because the surfaces that must mask (the TUI panels, completion) read the cached descriptor on a warm boot and never import the config model. Deriving it at render time would forfeit that; see contributor/guides/wiring-discipline.md and ADR-008.

from_config_model bool

True when this field came from a job's config model rather than from its plain signature.

The two are rendered by different rules, and a JobDescriptor carries one list for both (config_fields falls back to parameters), so the distinction has to travel with the field: parameters is not serialized, and a warm boot therefore sees only config_fields with no way to tell which kind it holds.

The rule that needs it: a config field's click option defaults to None, because the resolution ladder supplies the real value and an explicit default would arrive as though the user had typed it — outranking the config file, the environment, and everything else. A plain signature parameter has no ladder, so its default must be passed through. Rendering both alike is what made an app resolve a config field to its pydantic default from its second run onward.

name instance-attribute

type_annotation instance-attribute

default instance-attribute

description instance-attribute

required instance-attribute

choices = None class-attribute instance-attribute

positional = False class-attribute instance-attribute

short_flag = None class-attribute instance-attribute

is_stdin = False class-attribute instance-attribute

stdin_flag = None class-attribute instance-attribute

secret = False class-attribute instance-attribute

from_config_model = False class-attribute instance-attribute

type property

Backward-compatible alias for type_annotation.

help property

Backward-compatible alias for description.

JobDescriptor(name, group, function=None, docstring=None, parameters=list(), source='', metadata=dict(), module_path='', source_file='', source_mtime=0.0, content_hash='', config_fields=list(), dependencies=dict(), requires_tty=False, optional_tty=False, uses_live=False, suppress_live=(), decorators=(), surface_hint=None, declaration=None, workflow=None, from_job_deps=(), python_name='') dataclass

Serializable metadata for a discovered job.

Attributes:

Name Type Description
name str

Job function name.

group str | None

Job group name (None for top-level jobs).

function Callable[..., Any] | None

The callable job function (None for cache-only descriptors).

docstring str | None

Function docstring (None if absent).

parameters list[FieldDescriptor]

Config parameters for the job as FieldDescriptors.

source str

Module path or file path where the job was discovered.

metadata dict[str, Any]

Additional metadata about the job.

module_path str

Dotted module path for lazy import (defaults to source).

source_file str

Filesystem path to source file (for cache invalidation).

source_mtime float

Last modification time of source file.

content_hash str

Content hash for cache invalidation.

config_fields list[FieldDescriptor]

Alias for parameters (backward-compatible).

dependencies dict[str, str]

First-level in-project imports {abs_path: sha256}.

requires_tty bool

True if the signature declares a non-optional tty: TTY capability — a HARD requirement forcing EXCLUSIVE surface resolution; refused pre-flight in non-terminal contexts (MCP/CI/piped).

optional_tty bool

True if the signature declares tty: TTY | None — a preference: injected when EXCLUSIVE is grantable, else None, and the job degrades. Does not force EXCLUSIVE or trigger refusal.

uses_live bool

True if the signature declares a live: Live capability — a live-display channel bound per surface (always injected, degrading).

name instance-attribute

group instance-attribute

function = None class-attribute instance-attribute

docstring = None class-attribute instance-attribute

parameters = field(default_factory=list) class-attribute instance-attribute

source = '' class-attribute instance-attribute

metadata = field(default_factory=dict) class-attribute instance-attribute

module_path = '' class-attribute instance-attribute

source_file = '' class-attribute instance-attribute

source_mtime = 0.0 class-attribute instance-attribute

content_hash = '' class-attribute instance-attribute

config_fields = field(default_factory=list) class-attribute instance-attribute

dependencies = field(default_factory=dict) class-attribute instance-attribute

requires_tty = False class-attribute instance-attribute

optional_tty = False class-attribute instance-attribute

uses_live = False class-attribute instance-attribute

suppress_live = () class-attribute instance-attribute

decorators = () class-attribute instance-attribute

surface_hint = None class-attribute instance-attribute

declaration = None class-attribute instance-attribute

workflow = None class-attribute instance-attribute

from_job_deps = () class-attribute instance-attribute

python_name = '' class-attribute instance-attribute

func_name property

The leaf of the registered name — after the last dot, or all of it.

This is the canonical leaf (build-wheel), not the Python __name__ (build_wheel), because it is derived from name. Callers wanting the module attribute want :attr:python_name.

attribute_name property

The module attribute to resolve this job's function from.

to_dict()

Serialize to a JSON-compatible dict for cache persistence.

Uses config_fields if populated, otherwise falls back to parameters. Enum defaults are converted via .value attribute. Non-JSON-serializable defaults are converted to None.

Source code in src/functualize/_types/descriptors.py
def to_dict(self) -> dict[str, Any]:
    """Serialize to a JSON-compatible dict for cache persistence.

    Uses config_fields if populated, otherwise falls back to parameters.
    Enum defaults are converted via .value attribute. Non-JSON-serializable
    defaults are converted to None.
    """
    fields = self.config_fields if self.config_fields else self.parameters
    # metadata now holds only plugin extension data (a JSON dict) — the
    # @job_metadata annotation is gone; consumer-facing description/tags/
    # category live on `declaration`.
    metadata_dict: Any = dict(self.metadata) if self.metadata else None

    return {
        "name": self.name,
        "group": self.group,
        "module_path": self.module_path,
        "source_file": self.source_file,
        "source_mtime": self.source_mtime,
        "content_hash": self.content_hash,
        "docstring": self.docstring,
        "config_fields": [_field_to_dict(f) for f in fields],
        "dependencies": dict(self.dependencies),
        "metadata": metadata_dict,
        "requires_tty": self.requires_tty,
        "optional_tty": self.optional_tty,
        "uses_live": self.uses_live,
        "suppress_live": list(self.suppress_live),
        "surface_hint": self.surface_hint,
        "decorators": list(self.decorators),
        "declaration": (
            self.declaration.to_dict() if self.declaration is not None else None
        ),
        "from_job_deps": list(self.from_job_deps),
        "python_name": self.python_name,
        "workflow": (
            self.workflow.to_dict() if self.workflow is not None else None
        ),
    }

from_dict(data) classmethod

Deserialize from a JSON dict.

Raises:

Type Description
ValueError

If required keys are missing or values have wrong types.

Source code in src/functualize/_types/descriptors.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> JobDescriptor:
    """Deserialize from a JSON dict.

    Raises:
        ValueError: If required keys are missing or values have wrong types.
    """
    if not isinstance(data, dict):
        raise ValueError(
            f"Expected a dict for JobDescriptor, got {type(data).__name__}"
        )

    # Validate required top-level keys
    required_keys = {
        "name",
        "group",
        "module_path",
        "source_file",
        "source_mtime",
        "content_hash",
        "docstring",
        "config_fields",
        "dependencies",
    }
    missing = required_keys - set(data.keys())
    if missing:
        raise ValueError(
            f"Missing required keys in JobDescriptor dict: {sorted(missing)}"
        )

    # Validate types for scalar fields
    if not isinstance(data["name"], str):
        raise ValueError(
            f"Expected 'name' to be str, got {type(data['name']).__name__}"
        )
    if data["group"] is not None and not isinstance(data["group"], str):
        raise ValueError(
            f"Expected 'group' to be str or None, got {type(data['group']).__name__}"
        )
    if not isinstance(data["module_path"], str):
        raise ValueError(
            f"Expected 'module_path' to be str, got {type(data['module_path']).__name__}"
        )
    if not isinstance(data["source_file"], str):
        raise ValueError(
            f"Expected 'source_file' to be str, got {type(data['source_file']).__name__}"
        )
    if not isinstance(data["source_mtime"], int | float):
        raise ValueError(
            f"Expected 'source_mtime' to be a number, got {type(data['source_mtime']).__name__}"
        )
    if not isinstance(data["content_hash"], str):
        raise ValueError(
            f"Expected 'content_hash' to be str, got {type(data['content_hash']).__name__}"
        )
    if data["docstring"] is not None and not isinstance(data["docstring"], str):
        raise ValueError(
            f"Expected 'docstring' to be str or None, got {type(data['docstring']).__name__}"
        )
    if not isinstance(data["config_fields"], list):
        raise ValueError(
            f"Expected 'config_fields' to be a list, got {type(data['config_fields']).__name__}"
        )
    if not isinstance(data["dependencies"], dict):
        raise ValueError(
            f"Expected 'dependencies' to be a dict, got {type(data['dependencies']).__name__}"
        )

    # Deserialize config_fields
    config_fields = []
    for i, field_data in enumerate(data["config_fields"]):
        try:
            config_fields.append(_field_from_dict(field_data))
        except ValueError as e:
            raise ValueError(f"Invalid config_fields[{i}]: {e}") from e

    # Validate dependencies dict values
    for key, value in data["dependencies"].items():
        if not isinstance(key, str):
            raise ValueError(
                f"Expected dependency key to be str, got {type(key).__name__}"
            )
        if not isinstance(value, str):
            raise ValueError(
                f"Expected dependency value for '{key}' to be str, "
                f"got {type(value).__name__}"
            )

    # Deserialize metadata (plugin extension dict; empty for most jobs).
    raw_metadata = data.get("metadata")
    metadata_value: Any = (
        dict(raw_metadata) if isinstance(raw_metadata, dict) else {}
    )

    # Deserialize the @job declaration (v9). Absent/None in pre-v9 entries
    # and for convention jobs, which carry no declaration.
    declaration = None
    raw_declaration = data.get("declaration")
    if isinstance(raw_declaration, dict):
        from functualize._types.job_declaration import JobDeclaration

        declaration = JobDeclaration.from_dict(raw_declaration)

    # Deserialize the @workflow graph shape (v10). Absent for ordinary jobs
    # and pre-v10 entries; a malformed entry yields None (cache rebuild).
    workflow = None
    raw_workflow = data.get("workflow")
    if isinstance(raw_workflow, dict):
        from functualize._types.workflow import WorkflowShape

        workflow = WorkflowShape.from_dict(raw_workflow)

    return cls(
        name=data["name"],
        group=data["group"],
        module_path=data["module_path"],
        source_file=data["source_file"],
        source_mtime=float(data["source_mtime"]),
        content_hash=data["content_hash"],
        docstring=data["docstring"],
        config_fields=config_fields,
        dependencies=data["dependencies"],
        metadata=metadata_value,
        # Capability markers (v5) — default False for pre-v5 cache entries.
        requires_tty=bool(data.get("requires_tty", False)),
        optional_tty=bool(data.get("optional_tty", False)),
        uses_live=bool(data.get("uses_live", False)),
        # v6 — absent in pre-v6 cache entries, which suppress nothing.
        suppress_live=tuple(data.get("suppress_live", ()) or ()),
        # v7 — absent in pre-v7 cache entries, which state no preference.
        surface_hint=(
            data["surface_hint"]
            if isinstance(data.get("surface_hint"), str)
            else None
        ),
        # v8 — absent in pre-v8 cache entries, which recorded no decorators.
        decorators=tuple(data.get("decorators", ()) or ()),
        # v9 — absent in pre-v9 cache entries and convention jobs (None).
        declaration=declaration,
        # v10 — absent in pre-v10 entries and for every non-workflow job.
        from_job_deps=tuple(data.get("from_job_deps") or ()),
        python_name=data.get("python_name") or "",
        workflow=workflow,
    )

JobResult(status, return_value, duration_ms, metadata=dict(), exception=None, job_name='') dataclass

Result of a job execution.

Attributes:

Name Type Description
status RunStatus

The final run status of the job.

return_value Any

The value returned by the job function.

duration_ms float

Execution duration in milliseconds.

metadata dict[str, Any]

Additional metadata about the execution.

exception BaseException | None

The exception that caused failure, or None on success.

status instance-attribute

return_value instance-attribute

duration_ms instance-attribute

metadata = field(default_factory=dict) class-attribute instance-attribute

exception = field(default=None, compare=False, hash=False) class-attribute instance-attribute

job_name = '' class-attribute instance-attribute

Secret(value)

A string value marked secret — masked in every string rendering (§B.6).

str(secret) and repr(secret) return :data:MASK; the real value is only reachable through :meth:get_secret_value, so a secret dropped into an f-string, a log line, or a traceback shows ••• rather than leaking.

Secret[str] is a usable Pydantic field type: it accepts a plain str from any source (config file, environment, CLI) and wraps it, so a config author writes token: Secret[str] and gets a value that is masked everywhere without needing arbitrary_types_allowed.

Source code in src/functualize/_types/redaction.py
def __init__(self, value: Any) -> None:
    self._value = str(value)

__slots__ = ('_value',) class-attribute instance-attribute

get_secret_value()

Return the real, unmasked value for use at a trusted call site.

Source code in src/functualize/_types/redaction.py
def get_secret_value(self) -> str:
    """Return the real, unmasked value for use at a trusted call site."""
    return self._value

__str__()

Source code in src/functualize/_types/redaction.py
def __str__(self) -> str:
    return MASK

__repr__()

Source code in src/functualize/_types/redaction.py
def __repr__(self) -> str:
    return f"Secret({MASK!r})"

__eq__(other)

Source code in src/functualize/_types/redaction.py
def __eq__(self, other: object) -> bool:
    if isinstance(other, Secret):
        return self._value == other._value
    return NotImplemented

__hash__()

Source code in src/functualize/_types/redaction.py
def __hash__(self) -> int:
    return hash(self._value)

__class_getitem__(item)

Source code in src/functualize/_types/redaction.py
def __class_getitem__(cls, item: Any) -> types.GenericAlias:
    return types.GenericAlias(cls, item)

__get_pydantic_core_schema__(source, handler) classmethod

Accept a plain str (or an existing Secret) and wrap it.

Without this, token: Secret[str] on a plain BaseModel raises PydanticSchemaGenerationError at class-definition time, and the job declaring it disappears from func with only a warning on stderr — so the framework's own public secret type could not be used in the framework's own config models.

The serializer masks on the way out to JSON only (when_used="json"), which is the path that actually leaks: a resolved config reaching a file, a log sink, or an HTTP body without passing :func:redacted_snapshot. model_dump() in python mode returns the Secret itself, still masked by its own __str__.

Masking in python mode as well was tried and reverted. The framework passes config models through model_dump() internally — Invoke builds a child job's kwargs from one, RunContext.with_plugin_config rebuilds a model from one, the argument validator merges one back — so an unconditional serializer replaced live credentials with ••• between two of our own jobs, with no error and no warning. The child then authenticates with the mask string. Losing the real value silently is a worse failure than rendering it, and when_used="json" closes the leak without opening that.

Source code in src/functualize/_types/redaction.py
@classmethod
def __get_pydantic_core_schema__(cls, source: Any, handler: Any) -> Any:
    """Accept a plain ``str`` (or an existing ``Secret``) and wrap it.

    Without this, ``token: Secret[str]`` on a plain ``BaseModel`` raises
    ``PydanticSchemaGenerationError`` at class-definition time, and the job
    declaring it disappears from ``func`` with only a warning on stderr —
    so the framework's own public secret type could not be used in the
    framework's own config models.

    The serializer masks **on the way out to JSON only**
    (``when_used="json"``), which is the path that actually leaks: a
    resolved config reaching a file, a log sink, or an HTTP body without
    passing :func:`redacted_snapshot`. ``model_dump()`` in python mode
    returns the ``Secret`` itself, still masked by its own ``__str__``.

    Masking in python mode as well was tried and reverted. The framework
    passes config models through ``model_dump()`` internally — ``Invoke``
    builds a child job's kwargs from one, ``RunContext.with_plugin_config``
    rebuilds a model from one, the argument validator merges one back — so
    an unconditional serializer replaced live credentials with ``•••``
    *between* two of our own jobs, with no error and no warning. The child
    then authenticates with the mask string. Losing the real value silently
    is a worse failure than rendering it, and ``when_used="json"`` closes
    the leak without opening that.
    """
    from pydantic_core import core_schema

    return core_schema.no_info_after_validator_function(
        cls._validate,
        core_schema.union_schema(
            [
                core_schema.is_instance_schema(cls),
                core_schema.str_schema(),
            ]
        ),
        serialization=core_schema.plain_serializer_function_ser_schema(
            lambda _: MASK,
            return_schema=core_schema.str_schema(),
            when_used="json",
        ),
    )

__get_pydantic_json_schema__(schema, handler) classmethod

Emit {"secret": true} so the marker survives into the cache.

The TUI panels mask from the cached FieldDescriptor, which is built by reading model_json_schema() — a warm boot never imports the config model. Without this hook, Secret[str] would mask in info --job (which has the live FieldInfo) and leak in the TUI (which does not). This is what keeps the annotation and the json_schema_extra flag one mechanism rather than two that happen to agree in :func:is_secret_field.

Source code in src/functualize/_types/redaction.py
@classmethod
def __get_pydantic_json_schema__(cls, schema: Any, handler: Any) -> Any:
    """Emit ``{"secret": true}`` so the marker survives into the cache.

    The TUI panels mask from the *cached* ``FieldDescriptor``, which is
    built by reading ``model_json_schema()`` — a warm boot never imports the
    config model. Without this hook, ``Secret[str]`` would mask in
    ``info --job`` (which has the live ``FieldInfo``) and leak in the TUI
    (which does not). This is what keeps the annotation and the
    ``json_schema_extra`` flag one mechanism rather than two that happen to
    agree in :func:`is_secret_field`.
    """
    from pydantic_core import core_schema

    json_schema = handler(core_schema.str_schema())
    json_schema["secret"] = True
    return json_schema

ConfigFileRole

Bases: Enum

The role a discovered config file plays under the active environment.

Config files are named config.<slot>.<ext>. The base slot is always loaded; a slot matching the active environment is merged on top of it; any other slot belongs to a different environment and is not merged at all.

BASE = 'base' class-attribute instance-attribute

Always merged, regardless of the active environment.

OVERLAY = 'overlay' class-attribute instance-attribute

Slot matches the active environment — merged on top of BASE.

INERT = 'inert' class-attribute instance-attribute

Slot names a different environment — discovered, but never merged.

EnvironmentSource

Bases: Enum

Where the active environment name came from.

Delivery layers use this to distinguish "explicitly selected" from "fell back to the default", which are very different things to show a user staring at a config file that isn't taking effect.

FUNCTUALIZE_ENV = 'FUNCTUALIZE_ENV' class-attribute instance-attribute

ENVIRONMENT = 'ENVIRONMENT' class-attribute instance-attribute

ENV = 'ENV' class-attribute instance-attribute

DEFAULT = 'default' class-attribute instance-attribute

JobPhase

Bases: Enum

Named phases within a job execution lifecycle.

DISCOVERY = 'discovery' class-attribute instance-attribute

CONFIGURATION = 'configuration' class-attribute instance-attribute

VALIDATION = 'validation' class-attribute instance-attribute

EXECUTION = 'execution' class-attribute instance-attribute

TEARDOWN = 'teardown' class-attribute instance-attribute

RunStatus

Bases: Enum

Status of a run context execution.

SUCCESS = 'Success' class-attribute instance-attribute

FAILURE = 'Failure' class-attribute instance-attribute

BLOCKED = 'Blocked' class-attribute instance-attribute

SKIPPED = 'Skipped' class-attribute instance-attribute

RUNNING = 'Running' class-attribute instance-attribute

CANCELLED = 'Cancelled' class-attribute instance-attribute

TIMEOUT = 'Timeout' class-attribute instance-attribute

UNKNOWN = 'Unknown' class-attribute instance-attribute

REFUSED = 'Refused' class-attribute instance-attribute

resumable property

True when the run stopped at a declared pause point, not an error.

A workflow that reaches a Gate with no input is BLOCKED, not FAILURE: it did everything it was asked to, and re-invoking it with the input deposited continues from where it stopped. Callers that treat "not SUCCESS" as "broken" — CI gates, Deps satisfaction, TUI colouring — need to tell the two apart.

terminal property

True when the phase is over and cannot be transitioned from.

One definition, because there were two and they disagreed. _engine/capabilities/workflow.py and _engine/capabilities/runcontext.py each carried a _TERMINAL_STATES frozenset; one included REFUSED and one did not. The first one's own comment explains why the omission matters — "a refused step simply never gets end_time or duration, so it reads as still running in every consumer of this record" — and its sibling had exactly that defect, one module away. The runcontext copy's test mirrored the omission and asserted agreement with it, so the two could never converge by failing.

This is the state machine's question — may a phase transition out of here — and not the delivery question functualize.types.is_failure answers. The membership is the one workflow.py reasoned about, kept exactly: BLOCKED is a declared pause and resumable is the property that says so; SKIPPED is a phase a run can still move on from; RUNNING is the non-terminal case by definition; and UNKNOWN is deliberately left transitionable, because a status nobody could determine should not also be a state nobody can leave.

ran property

True when the job's body actually executed.

SKIPPED is not a failure — a guard or an up-to-date fingerprint answered "no work to do", which is the point of declaring them. But it is not SUCCESS either: a caller that treats them alike cannot tell a build that ran from one that was already current, and func why exists precisely to answer that.

RunType

Bases: Enum

Type of run context invocation.

JOB = 'job' class-attribute instance-attribute

COMMAND = 'command' class-attribute instance-attribute

RUN = 'run' class-attribute instance-attribute

ExitCode

Bases: IntEnum

Process exit codes functualize commits to.

OK = 0 class-attribute instance-attribute

JOB_RAISED = 1 class-attribute instance-attribute

USAGE = 2 class-attribute instance-attribute

REFUSED = 3 class-attribute instance-attribute

STALE = 4 class-attribute instance-attribute

BLOCKED = 5 class-attribute instance-attribute

Family

Bases: StrEnum

The kind of boundary a run's outcome is crossing.

A :class:~enum.StrEnum so a family can be logged, serialised and compared against a plain string without the caller unwrapping it. contracts.md spells it (str, Enum); on this interpreter that is the same type with a ruff warning attached (UP042), so the modern spelling is used.

PROCESS = 'process' class-attribute instance-attribute

An exit code a shell sees.

PANEL = 'panel' class-attribute instance-attribute

A rendered outcome inside a live surface, where the run stays addressable.

TOOL = 'tool' class-attribute instance-attribute

A status string in a tool response.

WIRE = 'wire' class-attribute instance-attribute

An HTTP status.

RunRequest(job_name, surface, kwargs=(lambda: _EMPTY)(), prompt_gates=False, output_format='auto', force=False, group_option_values=None, parent_scope=None, workflow_scope_id=None, invoke_depth=0, run_dependencies=True, force_fresh=False, cwd=None, job_directory=None, parent_run_id=None) dataclass

Everything one run needs, frozen at the door that built it.

The job is named, never resolved: :meth:JobExecutionEngine.run performs the lookup, as it already does for workflow steps and dependencies. Nothing outside _engine/ holds a job function in order to execute it.

surface has no default. A door must name itself — the coverage audit had to reconstruct which door a run came from by reading code, and the run record (F5) cannot record what the request never carried.

job_name instance-attribute

surface instance-attribute

kwargs = field(default_factory=(lambda: _EMPTY)) class-attribute instance-attribute

prompt_gates = False class-attribute instance-attribute

output_format = 'auto' class-attribute instance-attribute

force = False class-attribute instance-attribute

group_option_values = None class-attribute instance-attribute

parent_scope = None class-attribute instance-attribute

workflow_scope_id = None class-attribute instance-attribute

invoke_depth = 0 class-attribute instance-attribute

run_dependencies = True class-attribute instance-attribute

force_fresh = False class-attribute instance-attribute

cwd = None class-attribute instance-attribute

job_directory = None class-attribute instance-attribute

parent_run_id = None class-attribute instance-attribute

__post_init__()

Source code in src/functualize/_types/run_request.py
def __post_init__(self) -> None:
    if self.surface not in RUN_SURFACES:
        # Name the offender and the closed set; a door that mistypes itself
        # would otherwise travel all the way to the run record.
        raise ValueError(
            f"unknown surface {self.surface!r}; "
            f"expected one of {', '.join(sorted(RUN_SURFACES))}"
        )

__hash__()

Hash the scalar identity, not the payload.

kwargs and group_option_values are Mappings, and no mapping the callers actually pass is hashable — a request carrying a plain dict would make the whole object unhashable, which is the wrong trade for a value object that wants to key a cache or join a set. Equality still compares every field, so equal requests still hash equal; unequal requests may collide, which is all a hash promises.

Source code in src/functualize/_types/run_request.py
def __hash__(self) -> int:
    """Hash the scalar identity, not the payload.

    ``kwargs`` and ``group_option_values`` are ``Mapping``s, and no mapping
    the callers actually pass is hashable — a request carrying a plain dict
    would make the whole object unhashable, which is the wrong trade for a
    value object that wants to key a cache or join a set. Equality still
    compares every field, so equal requests still hash equal; unequal
    requests may collide, which is all a hash promises.
    """
    return hash(
        (
            self.job_name,
            self.surface,
            self.prompt_gates,
            self.output_format,
            self.force,
            self.workflow_scope_id,
            self.invoke_depth,
            self.run_dependencies,
            self.force_fresh,
            self.cwd,
            self.job_directory,
            self.parent_run_id,
        )
    )

replace(**changes)

Return a copy with changes applied.

The engine re-points a request at a dependency or a workflow step this way, so the surface and delivery inputs travel with it instead of being re-derived at the second hop.

Source code in src/functualize/_types/run_request.py
def replace(self, **changes: Any) -> RunRequest:
    """Return a copy with ``changes`` applied.

    The engine re-points a request at a dependency or a workflow step this
    way, so the surface and delivery inputs travel with it instead of being
    re-derived at the second hop.
    """
    return _dc_replace(self, **changes)

exit_code_for_status(status)

The process exit code a finished run should terminate with.

Parameters:

Name Type Description Default
status RunStatus

The terminal :class:RunStatus of the run.

required

Returns:

Type Description
ExitCode

The pinned :class:ExitCode. Unmapped statuses answer

ExitCode

JOB_RAISED — a run that ended in a state the boundary does not

ExitCode

recognise is a failure, and inventing a code here would put an

ExitCode

unpinned number into the contract.

Source code in src/functualize/_types/exit_codes.py
def exit_code_for_status(status: RunStatus) -> ExitCode:
    """The process exit code a finished run should terminate with.

    Args:
        status: The terminal :class:`RunStatus` of the run.

    Returns:
        The pinned :class:`ExitCode`. Unmapped statuses answer
        ``JOB_RAISED`` — a run that ended in a state the boundary does not
        recognise is a failure, and inventing a code here would put an
        unpinned number into the contract.
    """
    return _STATUS_EXIT_CODES.get(status, ExitCode.JOB_RAISED)

flag_aliases(field)

Every spelling that selects field positively.

The long form is derived from the field name with underscores hyphenated (dry_run -> --dry-run), matching what the click param builder renders, plus the undecorated --dry_run so the name as written also works. A short flag is included when the Option marker declared one.

Source code in src/functualize/_types/flag_grammar.py
def flag_aliases(field: FieldDescriptor) -> tuple[str, ...]:
    """Every spelling that selects ``field`` **positively**.

    The long form is derived from the field name with underscores hyphenated
    (``dry_run`` -> ``--dry-run``), matching what the click param builder
    renders, plus the undecorated ``--dry_run`` so the name as written also
    works. A short flag is included when the ``Option`` marker declared one.
    """
    names = [f"--{field.name.replace('_', '-')}"]
    if "_" in field.name:
        names.append(f"--{field.name}")
    if field.short_flag:
        names.append(field.short_flag)
    return tuple(names)

match_group_flag(token, specs)

Find the field a mid-path token selects, if any declares it.

Searched nearest-declaration-first so a nested group may shadow an ancestor's flag. Returns (field, inline_value, negated) where inline_value is the right-hand side of a --flag=value spelling and negated says the --no- spelling was used.

negated is a third element rather than a synthesised inline="false" because the caller must tell --no-strict from --strict=false: the first is the supported spelling and the second is refused.

Source code in src/functualize/_types/flag_grammar.py
def match_group_flag(
    token: str, specs: Sequence[GroupOptionsSpec]
) -> tuple[FieldDescriptor, str | None, bool] | None:
    """Find the field a mid-path ``token`` selects, if any declares it.

    Searched nearest-declaration-first so a nested group may shadow an
    ancestor's flag. Returns ``(field, inline_value, negated)`` where
    ``inline_value`` is the right-hand side of a ``--flag=value`` spelling and
    ``negated`` says the ``--no-`` spelling was used.

    ``negated`` is a third element rather than a synthesised ``inline="false"``
    because the caller must tell ``--no-strict`` from ``--strict=false``: the
    first is the supported spelling and the second is refused.
    """
    name, separator, inline = token.partition("=")
    for spec in reversed(specs):
        siblings = [f.name for f in spec.fields]
        for spec_field in spec.fields:
            if name in flag_aliases(spec_field):
                return spec_field, (inline if separator else None), False
            if name in negative_aliases(spec_field, siblings):
                return spec_field, (inline if separator else None), True
    return None

negative_aliases(field, siblings)

Every spelling that selects field negatively, for a bool.

Empty for a non-boolean, and empty when a sibling literally named no_<name> owns the spelling — the same rule the click builders render from. Two surfaces asking one function is the point: if they decided independently, --no-cache would mean different things depending on how the program was invoked.

Source code in src/functualize/_types/flag_grammar.py
def negative_aliases(
    field: FieldDescriptor, siblings: Sequence[str]
) -> tuple[str, ...]:
    """Every spelling that selects ``field`` **negatively**, for a bool.

    Empty for a non-boolean, and empty when a sibling literally named
    ``no_<name>`` owns the spelling — the same rule the click builders render
    from. Two surfaces asking one function is the point: if they decided
    independently, ``--no-cache`` would mean different things depending on how
    the program was invoked.
    """
    if (field.type_annotation or "") != "bool":
        return ()

    negative = negative_flag_for(field.name, siblings)
    if negative is None:
        return ()
    names = [negative]
    if "_" in field.name:
        names.append(f"--no_{field.name}")
    return tuple(names)

negative_flag_for(name, siblings=())

The --no- spelling that turns boolean field name off.

Returns None when a sibling field is literally called no_<name>. That field owns the spelling, and name renders with no negative form.

The rule exists because click will not enforce one. Declaring both cache and no_cache gives two parameters contending for --no-cache, and click raises nothing — it binds whichever was declared first, so the same two fields produce opposite results depending on the order they were written in. That silent, order-dependent shadowing is the defect; this function's guarantee is determinism, not detection. A user gets a working CLI in which one field simply has no negative spelling.

Lives here, and is re-exported through functualize.app.utils, because two surfaces must agree on it: the click param builders render the flag, and func's pre-boot dispatch parser matches it mid-path. If they decided this independently, --no-cache would come to mean different things depending on how the program was invoked — the divergence class that already produced three disagreeing dependency resolvers here.

Parameters:

Name Type Description Default
name str

The boolean field's Python name (dry_run, not --dry-run).

required
siblings Iterable[str]

Every field name visible on the same command, this one included. Passing only some of them re-opens the collision.

()

Returns:

Type Description
str | None

--no-<hyphenated-name>, or None when a sibling owns it.

Source code in src/functualize/_types/flag_grammar.py
def negative_flag_for(name: str, siblings: Iterable[str] = ()) -> str | None:
    """The ``--no-`` spelling that turns boolean field ``name`` off.

    Returns ``None`` when a **sibling field is literally called** ``no_<name>``.
    That field owns the spelling, and ``name`` renders with no negative form.

    The rule exists because click will not enforce one. Declaring both ``cache``
    and ``no_cache`` gives two parameters contending for ``--no-cache``, and
    click raises nothing — it binds whichever was declared first, so the same
    two fields produce opposite results depending on the order they were
    written in. That silent, order-dependent shadowing is the defect; this
    function's guarantee is **determinism**, not detection. A user gets a
    working CLI in which one field simply has no negative spelling.

    Lives here, and is re-exported through ``functualize.app.utils``, because
    two surfaces must agree on it: the click param builders render the flag,
    and ``func``'s pre-boot dispatch parser matches it mid-path. If they decided
    this independently, ``--no-cache`` would come to mean different things
    depending on how the program was invoked — the divergence class that
    already produced three disagreeing dependency resolvers here.

    Args:
        name: The boolean field's Python name (``dry_run``, not ``--dry-run``).
        siblings: Every field name visible on the same command, this one
            included. Passing only some of them re-opens the collision.

    Returns:
        ``--no-<hyphenated-name>``, or ``None`` when a sibling owns it.
    """
    if f"no_{name}" in siblings:
        return None
    return f"--no-{name.replace('_', '-')}"

http_status_for_status(status)

The HTTP status code a finished run should be reported with.

Parameters:

Name Type Description Default
status RunStatus

The terminal :class:RunStatus of the run.

required

Returns:

Type Description
int

The HTTP status code. Unmapped statuses — only RUNNING, which is

int

never observed at a request boundary — fall back to 500 rather

int

than inventing a code.

Source code in src/functualize/_types/http_status.py
def http_status_for_status(status: RunStatus) -> int:
    """The HTTP status code a finished run should be reported with.

    Args:
        status: The terminal :class:`RunStatus` of the run.

    Returns:
        The HTTP status code. Unmapped statuses — only ``RUNNING``, which is
        never observed at a request boundary — fall back to ``500`` rather
        than inventing a code.
    """
    return _STATUS_HTTP_CODES.get(status, 500)

is_failure(status, *, family)

Is this outcome a failure at family's boundary?

The rule that was spelled three times, with the family as an argument instead of as a comment. is_failure(BLOCKED, family=PROCESS) is True; is_failure(BLOCKED, family=PANEL) is False.

RUNNING is transient and never observed at a boundary; asking about it is a caller bug, and it answers True rather than inventing a fifth outcome, matching how the exit-code table treats anything unmapped.

Source code in src/functualize/_types/outcome.py
def is_failure(status: RunStatus, *, family: Family) -> bool:
    """Is this outcome a failure at ``family``'s boundary?

    The rule that was spelled three times, with the family as an argument
    instead of as a comment. ``is_failure(BLOCKED, family=PROCESS)`` is ``True``;
    ``is_failure(BLOCKED, family=PANEL)`` is ``False``.

    ``RUNNING`` is transient and never observed at a boundary; asking about it
    is a caller bug, and it answers ``True`` rather than inventing a fifth
    outcome, matching how the exit-code table treats anything unmapped.
    """
    return status not in _NOT_A_FAILURE[family]

report_line(status)

The one line a status owes the caller before its code is delivered.

Two statuses have something to say first, and saying nothing is worse than saying it twice:

  • BLOCKED — without the message a paused run looks like a plain success to anything reading only stdout;
  • REFUSED — a stage that declined because its declared inputs were absent must reach the boundary, or silence plus exit 0 reads as "verified, nothing wrong". That is precisely the false clean.

Returns None for every status that owes nothing, so a caller can write if (line := report_line(status)): without a second table.

The detail — which gate, which scope, the resume incantation — stays with the surface that has the result object. This is the sentence, not the report.

Source code in src/functualize/_types/outcome.py
def report_line(status: RunStatus) -> str | None:
    """The one line a status owes the caller before its code is delivered.

    Two statuses have something to say first, and saying nothing is worse than
    saying it twice:

    * ``BLOCKED`` — without the message a paused run looks like a plain success
      to anything reading only stdout;
    * ``REFUSED`` — a stage that declined because its declared inputs were
      absent must reach the boundary, or silence plus exit 0 reads as
      "verified, nothing wrong". That is precisely the false clean.

    Returns ``None`` for every status that owes nothing, so a caller can write
    ``if (line := report_line(status)):`` without a second table.

    The *detail* — which gate, which scope, the resume incantation — stays with
    the surface that has the result object. This is the sentence, not the report.
    """
    if status is RunStatus.BLOCKED:
        return "Blocked: the run paused at a declared gate and is resumable."
    if status is RunStatus.REFUSED:
        return "Refused: a declared precondition for running this job was not met."
    return None

status_from_wire(value)

Read a status string back, or None if it names no status.

This replaces the hand-rolled reverse lookup in _cli/builtins.py, whose fallback — 0 if status in {"answered", "drafted"} else 1 — invented two outcomes the RunStatus vocabulary does not have and mapped everything else to a bare failure. Returning None hands the decision back to the caller with the fact that the string was unrecognised, which is the thing the fallback threw away.

Source code in src/functualize/_types/outcome.py
def status_from_wire(value: str) -> RunStatus | None:
    """Read a status string back, or ``None`` if it names no status.

    This replaces the hand-rolled reverse lookup in ``_cli/builtins.py``, whose
    fallback — ``0 if status in {"answered", "drafted"} else 1`` — invented two
    outcomes the ``RunStatus`` vocabulary does not have and mapped everything
    else to a bare failure. Returning ``None`` hands the decision back to the
    caller with the fact that the string was unrecognised, which is the thing
    the fallback threw away.
    """
    wanted = value.strip().lower()
    for member in RunStatus:
        if member.value.lower() == wanted:
            return member
    return None

wire_value(status)

The status string a tool response carries.

Lowercase, stable, and the inverse of :func:status_from_wire. Callers branch on these strings, so they are an interface, not a rendering.

Source code in src/functualize/_types/outcome.py
def wire_value(status: RunStatus) -> str:
    """The status string a tool response carries.

    Lowercase, stable, and the inverse of :func:`status_from_wire`. Callers
    branch on these strings, so they are an interface, not a rendering.
    """
    return status.value.lower()

request_from_envelope(payload, *, job_name, surface)

Parse a wire payload into a request — the one copy of that contract.

The shape is an envelope: the job's own parameters live in a nested arguments object and the control inputs sit beside it, never inside::

{"arguments": {"target": "prod"},
 "group_option_values": {"env": "staging"},
 "scope_id": "run-42",
 "force": true}

The nesting is the fix, not decoration. A flat body meant a caller's key could bind to a control parameter — send {"scope_id": "x"} and you were choosing the workflow scope the run joined rather than passing an argument (spec AC-17a). Nested, a job parameter literally named scope_id arrives as an argument and the scope stays a separate, deliberate choice.

scope_id is also what makes a gated workflow resumable over the wire: start it, read the scope id back from the result metadata, answer the gate, send the same id again. The audit recorded that as impossible (D-6) because there was no field to put it in.

Breaking, deliberately. Job parameters used to be the whole body; they are now under arguments.

Why it lives here. It was written twice — HTTP and Lambda held byte-identical copies differing only in the surface literal — and restated in prose twice more at MCP's two doors. One wire contract maintained in four places, with the breaking change documented four times and three of the four citations wrong (two said "risk R-a, spec AC-9", one said "spec AC-4, AC-9"; the criterion is AC-17a). A reader auditing AC-17a through its keeper would conclude the wire doors were uncovered. This module is stdlib-only and every one of those doors already imports RunRequest from it, so there is no layering reason for the copies to exist (rre F12).

Parameters:

Name Type Description Default
payload Mapping[str, Any]

The decoded wire body.

required
job_name str

The job the door resolved.

required
surface RunSurface

The calling door — the only thing that differed between the two copies, and now a parameter rather than a reason to fork.

required

Raises:

Type Description
ValueError

A field is present with the wrong JSON type. Each is reported by name, because "invalid payload" sends the caller hunting through a body they thought was correct.

Source code in src/functualize/_types/run_request.py
def request_from_envelope(
    payload: Mapping[str, Any],
    *,
    job_name: str,
    surface: RunSurface,
) -> RunRequest:
    """Parse a wire payload into a request — the one copy of that contract.

    The shape is an **envelope**: the job's own parameters live in a nested
    ``arguments`` object and the control inputs sit beside it, never inside::

        {"arguments": {"target": "prod"},
         "group_option_values": {"env": "staging"},
         "scope_id": "run-42",
         "force": true}

    The nesting is the fix, not decoration. A flat body meant a caller's key
    could bind to a control parameter — send ``{"scope_id": "x"}`` and you were
    choosing the workflow scope the run joined rather than passing an argument
    (**spec AC-17a**). Nested, a job parameter literally named ``scope_id``
    arrives as an argument and the scope stays a separate, deliberate choice.

    ``scope_id`` is also what makes a gated workflow **resumable over the wire**:
    start it, read the scope id back from the result metadata, answer the gate,
    send the same id again. The audit recorded that as impossible (D-6) because
    there was no field to put it in.

    **Breaking, deliberately.** Job parameters used to be the whole body; they
    are now under ``arguments``.

    **Why it lives here.** It was written twice — HTTP and Lambda held
    byte-identical copies differing only in the ``surface`` literal — and
    restated in prose twice more at MCP's two doors. One wire contract
    maintained in four places, with the breaking change documented four times
    and *three of the four citations wrong* (two said "risk R-a, spec AC-9",
    one said "spec AC-4, AC-9"; the criterion is AC-17a). A reader auditing
    AC-17a through its keeper would conclude the wire doors were uncovered.
    This module is stdlib-only and every one of those doors already imports
    ``RunRequest`` from it, so there is no layering reason for the copies to
    exist (rre F12).

    Args:
        payload: The decoded wire body.
        job_name: The job the door resolved.
        surface: The calling door — the only thing that differed between the
            two copies, and now a parameter rather than a reason to fork.

    Raises:
        ValueError: A field is present with the wrong JSON type. Each is
            reported by name, because "invalid payload" sends the caller
            hunting through a body they thought was correct.
    """
    arguments = payload.get("arguments") or {}
    if not isinstance(arguments, dict):
        raise ValueError("'arguments' must be a JSON object")
    group_options = payload.get("group_option_values") or None
    if group_options is not None and not isinstance(group_options, dict):
        raise ValueError("'group_option_values' must be a JSON object")
    scope_id = payload.get("scope_id")
    if scope_id is not None and not isinstance(scope_id, str):
        raise ValueError("'scope_id' must be a string")
    return RunRequest(
        job_name=job_name,
        surface=surface,
        kwargs=arguments,
        group_option_values=group_options,
        workflow_scope_id=scope_id,
        force=bool(payload.get("force", False)),
    )

Overview

functualize.types is the shared type vocabulary for job authors, plugin authors, and app constructors. This page documents the run-model slice of that vocabulary — the door a run entered through, and how a finished run's status translates into what a delivery surface actually reports. It is the largest addition to functualize.types in this release; see Discovery (Internal) for JobDescriptor/FieldDescriptor and Config (Internal) for the config-file and environment vocabulary.

Module location: src/functualize/_types/run_request.py (surface vocabulary) and src/functualize/_types/outcome.py (status translation).

Public API

from functualize.types import (
    RunSurface,
    RUN_SURFACES,
    request_from_envelope,
    wire_value,
    status_from_wire,
    report_line,
)

RunSurface and RUN_SURFACES

RunSurface is a Literal naming every door a run can enter through — the one field every RunRequest (functualize.types.RunRequest, the frozen dataclass every entry surface builds and JobExecutionEngine.run consumes) must set, with no default, because a door must name itself:

RunSurface = Literal[
    "func.job", "func.group", "func.single-file",
    "app.cli", "app.execute", "func.builtin",
    "tui.inline", "tui.shell",
    "mcp.tool", "mcp.run-job", "mcp.async",
    "http", "lambda",
    "invoke", "invoke.parallel", "app.parallel",
    "event.job-submit", "engine.step", "engine.dependency",
]

RUN_SURFACES is the frozenset[str] of those same 19 values, derived from the Literal via get_args() rather than typed out a second time, so the two vocabularies cannot drift:

from functualize.types import RUN_SURFACES, RunSurface

def build_request(surface: RunSurface) -> None:
    if surface not in RUN_SURFACES:
        raise ValueError(f"unknown surface {surface!r}")

A RunRequest constructed with a surface outside RUN_SURFACES raises ValueError naming the offender and the closed set, so a door that mistypes its own name is caught at construction rather than traveling all the way to the run record.


request_from_envelope

def request_from_envelope(
    payload: Mapping[str, Any],
    *,
    job_name: str,
    surface: RunSurface,
) -> RunRequest: ...

Parses a wire payload into a RunRequest — the one shared implementation for every out-of-process door (HTTP, Lambda, and MCP's two doors), which previously carried byte-identical copies of this parsing differing only in the surface literal.

The payload is an envelope: a job's own parameters live under a nested "arguments" key, and control inputs sit beside it, never inside:

{
    "arguments": {"target": "prod"},
    "group_option_values": {"env": "staging"},
    "scope_id": "run-42",
    "force": True,
}

This nesting is deliberate, not decoration — a flat body let a caller's own argument name collide with a control field (sending {"scope_id": "x"} silently chose the workflow scope a run joined instead of passing an argument). scope_id is also what makes a gated workflow resumable over the wire: start it, read the scope id back from the result metadata, answer the gate, send the same id again.

from functualize.types import request_from_envelope

request = request_from_envelope(
    {"arguments": {"target": "prod"}, "force": True},
    job_name="deploy",
    surface="http",
)

Breaking, deliberately. Job parameters used to be the whole request body; they are now nested under "arguments".

Raises: ValueError — a field is present with the wrong JSON type (arguments not an object, group_option_values not an object, scope_id not a string). Each case is reported by name.


wire_value and status_from_wire

The inverse pair that turns a RunStatus into the status string a tool or HTTP response carries, and reads one back:

def wire_value(status: RunStatus) -> str: ...
def status_from_wire(value: str) -> RunStatus | None: ...
from functualize.types import RunStatus, status_from_wire, wire_value

wire_value(RunStatus.BLOCKED)        # "blocked"
status_from_wire("blocked")          # RunStatus.BLOCKED
status_from_wire("not-a-status")     # None

wire_value is status.value.lower() — lowercase, stable, and an interface callers branch on rather than a rendering choice. status_from_wire does the reverse lookup and returns None when the string names no status, handing the "this was unrecognised" fact back to the caller instead of guessing a fallback outcome.


report_line

def report_line(status: RunStatus) -> str | None: ...

The one line a status owes the caller before its exit code, HTTP status, or tool string is delivered. Only two statuses have something to say first:

from functualize.types import RunStatus, report_line

report_line(RunStatus.BLOCKED)
# "Blocked: the run paused at a declared gate and is resumable."
report_line(RunStatus.REFUSED)
# "Refused: a declared precondition for running this job was not met."
report_line(RunStatus.SUCCESS)
# None

Returns None for every status that owes nothing, so a caller can write if (line := report_line(status)): without a second table. The detail — which gate, which scope, the resume incantation — stays with the surface that holds the result object; this is the sentence, not the report.

Why this exists

Nine call sites used to translate a RunStatus into something a caller could act on, each with its own copy of the rules, and two of them disagreed about whether a paused (BLOCKED) run counts as a failure. functualize.types is now the single authority: a delivery surface asks, it does not decide. See report_line's sibling is_failure(status, *, family=...) for the family-scoped failure question (not part of this page's public surface).

Global flag vocabulary

GLOBAL_BOOL_FLAGS, GLOBAL_OPTIONS_OPTIONAL_VALUE, GLOBAL_OPTIONS_WITH_VALUE and OPTIONAL_VALUE_VALID_SET are the four tables that say how functualize's global options consume the tokens after them. They are public because the CLI is forbidden from reaching _types/ directly — _cli/ may import public folders only — so the re-export is the sanctioned route, not an accident.

Global options fall into three kinds, and the difference is entirely about the next token:

Table Meaning Members
GLOBAL_OPTIONS_ALWAYS_VALUE Always consumes the next token --log-level, --dotenv-file, --config-directory, --discovery-depth, the seven --require-* filters, --exclude, --perf-filter, --import-libs
GLOBAL_OPTIONS_OPTIONAL_VALUE May consume the next token, by lookahead --perf-report, --emit-format
GLOBAL_BOOL_FLAGS Never consumes a token --no-dotenv, --prompt-gates, --no-prompt-gates, --force, --help, -h

GLOBAL_OPTIONS_WITH_VALUE is the union of the first two — every flag that could be followed by a value — and exists for --option=value detection, where the distinction between "always" and "maybe" does not apply.

OPTIONAL_VALUE_VALID_SET

The lookahead needs to know what a legal value looks like, because that is the only thing separating a value from the next argument. This maps each optional-value flag to (valid_values, default):

from functualize.types import OPTIONAL_VALUE_VALID_SET

OPTIONAL_VALUE_VALID_SET["--perf-report"]
# (frozenset({'text', 'json'}), 'text')

OPTIONAL_VALUE_VALID_SET["--emit-format"]
# (frozenset({'auto', 'json', 'ndjson', 'raw', 'none'}), 'auto')

So func --perf-report deploy runs the deploy job with a text report, while func --perf-report json deploy consumes json as the report format. deploy is not in the valid set, so it is left alone as the job name.

Note that auto is both --emit-format's default and a typeable value. A bare --emit-format falls back to it through the lookahead, and that fallback is validated like any other, so it has to be legal — which also lets a caller name the default explicitly.

--emit-format governs out.emit() and nothing else

A job's return value is never rendered at any format, and print() ignores the flag entirely. It was called --output until 2026-09-10 and renamed precisely because that name promises to control "the command's output" and does not. There is deliberately no alias: pre-alpha, the constitution says delete rather than shim.

--version is absent from GLOBAL_BOOL_FLAGS

It is handled by a pre-boot fast path in _cli/main.py and is position-aware — recognised only before the first positional argument. --help and -h are listed, because Click needs to see them to render per-command help.

Alias matching for job and group flags — as opposed to these global ones — is flag_aliases, negative_aliases, match_group_flag and negative_flag_for, exported from the same module.