Skip to content

API Reference

The public surface is exactly what is exported from spine_lite.__all__. Everything else is private and subject to change without notice.

Top-level

The package root re-exports the surface so callers don't have to navigate submodules:

from spine_lite import (
    Effect,
    PRECEDENCE,
    most_restrictive,
    SpineLiteError,
    ManifestError,
    ClassificationError,
    PostureError,
    HookError,
    __version__,
)

Effects

spine_lite.effects

Closed effects taxonomy.

Six-class enumeration of side-effect categories that a tool call can produce. The ordering encoded in :data:PRECEDENCE is the spec — DESTRUCTIVE dominates, READ yields. :func:most_restrictive collapses any non-empty set of effects to the single highest-precedence class.

This module is pure: no I/O, no clocks, no randomness. The taxonomy is closed by design and mirrors the TypeScript reference at https://github.com/MacFall7/M87-Spine-lite. Extensions require explicit project sign-off.

PRECEDENCE module-attribute

PRECEDENCE: Final[tuple[Effect, ...]] = (
    DESTRUCTIVE,
    SPAWN,
    EXECUTE,
    NETWORK,
    WRITE,
    READ,
)

Effect classes ordered from most restrictive to least restrictive.

Effect

Bases: StrEnum

Side-effect class produced by a tool call.

Members

READ: pure observation; no state change. WRITE: persistent state change to caller-owned storage. NETWORK: outbound network call. EXECUTE: subprocess invocation, no fork-and-detach. SPAWN: subprocess invocation that may fork-and-detach. DESTRUCTIVE: irreversible state change (delete, drop, force-push).

Source code in src/spine_lite/effects.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Effect(StrEnum):
    """Side-effect class produced by a tool call.

    Members:
        READ: pure observation; no state change.
        WRITE: persistent state change to caller-owned storage.
        NETWORK: outbound network call.
        EXECUTE: subprocess invocation, no fork-and-detach.
        SPAWN: subprocess invocation that may fork-and-detach.
        DESTRUCTIVE: irreversible state change (delete, drop, force-push).
    """

    READ = "read"
    WRITE = "write"
    NETWORK = "network"
    EXECUTE = "execute"
    SPAWN = "spawn"
    DESTRUCTIVE = "destructive"

most_restrictive

most_restrictive(effects: Iterable[Effect]) -> Effect

Return the highest-precedence effect from effects.

Parameters:

Name Type Description Default
effects Iterable[Effect]

Non-empty iterable of effects. Order is irrelevant.

required

Returns:

Type Description
Effect

The single effect that dominates all others under :data:PRECEDENCE.

Raises:

Type Description
ValueError

If effects is empty.

Examples:

>>> most_restrictive({Effect.READ, Effect.NETWORK})
<Effect.NETWORK: 'network'>
>>> most_restrictive([Effect.DESTRUCTIVE, Effect.READ])
<Effect.DESTRUCTIVE: 'destructive'>
Source code in src/spine_lite/effects.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def most_restrictive(effects: Iterable[Effect]) -> Effect:
    """Return the highest-precedence effect from ``effects``.

    Args:
        effects: Non-empty iterable of effects. Order is irrelevant.

    Returns:
        The single effect that dominates all others under :data:`PRECEDENCE`.

    Raises:
        ValueError: If ``effects`` is empty.

    Examples:
        >>> most_restrictive({Effect.READ, Effect.NETWORK})
        <Effect.NETWORK: 'network'>
        >>> most_restrictive([Effect.DESTRUCTIVE, Effect.READ])
        <Effect.DESTRUCTIVE: 'destructive'>
    """
    materialised = frozenset(effects)
    if not materialised:
        raise ValueError("effects must be non-empty")
    for candidate in PRECEDENCE:
        if candidate in materialised:
            return candidate
    # Unreachable while the taxonomy and PRECEDENCE stay in sync; if a new
    # member is added without updating PRECEDENCE this hard-fails on the spot.
    raise AssertionError("effect outside PRECEDENCE")  # pragma: no cover

Exceptions

spine_lite.exceptions

Exception hierarchy.

All errors raised by spine-lite inherit from :class:SpineLiteError. Library callers can catch the base class for blanket handling or the specific subclasses for finer control. The hierarchy is closed in spirit — adding new classes is permitted, but every new class must descend from the base.

Pure module: no I/O, no side effects.

SpineLiteError

Bases: Exception

Base class for every error raised by spine-lite.

Source code in src/spine_lite/exceptions.py
14
15
class SpineLiteError(Exception):
    """Base class for every error raised by spine-lite."""

ManifestError

Bases: SpineLiteError

Raised when a tool manifest is malformed or fails validation.

Source code in src/spine_lite/exceptions.py
18
19
class ManifestError(SpineLiteError):
    """Raised when a tool manifest is malformed or fails validation."""

ClassificationError

Bases: SpineLiteError

Raised when a tool call cannot be classified against a manifest.

Source code in src/spine_lite/exceptions.py
22
23
class ClassificationError(SpineLiteError):
    """Raised when a tool call cannot be classified against a manifest."""

PostureError

Bases: SpineLiteError

Raised on illegal posture transitions or invalid posture state.

Source code in src/spine_lite/exceptions.py
26
27
class PostureError(SpineLiteError):
    """Raised on illegal posture transitions or invalid posture state."""

HookError

Bases: SpineLiteError

Raised when the PreToolUse hook protocol is violated.

Source code in src/spine_lite/exceptions.py
30
31
class HookError(SpineLiteError):
    """Raised when the PreToolUse hook protocol is violated."""

Manifest

spine_lite.manifest

Tool-manifest schema.

Pydantic v2 models for tool definitions, declared effects, and posture constraints. Pure module: validation only, no I/O.

Manifests round-trip authored fixtures byte-for-byte. The two order-sensitive fields — :attr:ToolDefinition.effects and :attr:ToolDefinition.permitted_postures — are canonicalised on construction (deduplicated and sorted by enum-declaration order) so JSON serialisation is stable across runs and platforms regardless of the order the author wrote them in.

ToolDefinition

Bases: BaseModel

Declares a single tool's effects and posture constraints.

Attributes:

Name Type Description
name str

Tool identifier as the LLM sees it. Must match the key under which this definition is registered in a :class:Manifest.

description str | None

Optional human-readable description.

effects tuple[Effect, ...]

Effect classes this tool's invocations can produce. Must be non-empty. Stored canonically: deduplicated and sorted by PRECEDENCE order.

permitted_postures tuple[Posture, ...] | None

Postures under which this tool may be invoked. None means no posture constraint (the tool runs under any posture). When set, must be non-empty. Stored canonically: deduplicated and sorted by :class:Posture declaration order.

require_confirmation bool

If true, even an otherwise-allowed call must be confirmed by the operator before execution. Phase 3 classifier honours this; Phase 2 just stores it.

metadata dict[str, Any]

Free-form additional metadata. Manifest authors may carry arbitrary keys here; spine-lite ignores them but preserves them for round-trip serialisation.

Examples:

>>> definition = ToolDefinition(
...     name="read_file",
...     effects=(Effect.READ,),
... )
>>> definition.effects
(<Effect.READ: 'read'>,)
Source code in src/spine_lite/manifest.py
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
class ToolDefinition(BaseModel):
    """Declares a single tool's effects and posture constraints.

    Attributes:
        name: Tool identifier as the LLM sees it. Must match the key under
            which this definition is registered in a :class:`Manifest`.
        description: Optional human-readable description.
        effects: Effect classes this tool's invocations can produce. Must
            be non-empty. Stored canonically: deduplicated and sorted by
            ``PRECEDENCE`` order.
        permitted_postures: Postures under which this tool may be invoked.
            ``None`` means no posture constraint (the tool runs under any
            posture). When set, must be non-empty. Stored canonically:
            deduplicated and sorted by :class:`Posture` declaration order.
        require_confirmation: If true, even an otherwise-allowed call must
            be confirmed by the operator before execution. Phase 3
            classifier honours this; Phase 2 just stores it.
        metadata: Free-form additional metadata. Manifest authors may
            carry arbitrary keys here; spine-lite ignores them but
            preserves them for round-trip serialisation.

    Examples:
        >>> definition = ToolDefinition(
        ...     name="read_file",
        ...     effects=(Effect.READ,),
        ... )
        >>> definition.effects
        (<Effect.READ: 'read'>,)
    """

    model_config: ClassVar[ConfigDict] = ConfigDict(
        frozen=True,
        extra="forbid",
        validate_default=True,
        str_strip_whitespace=True,
    )

    name: str = Field(min_length=1)
    description: str | None = None
    effects: tuple[Effect, ...] = Field(min_length=1)
    permitted_postures: tuple[Posture, ...] | None = None
    require_confirmation: bool = False
    metadata: dict[str, Any] = Field(default_factory=dict)

    @field_validator("effects", mode="after")
    @classmethod
    def _canonicalise_effects(
        cls,
        value: tuple[Effect, ...],
    ) -> tuple[Effect, ...]:
        return _canonical_effects(value)

    @field_validator("permitted_postures", mode="after")
    @classmethod
    def _canonicalise_postures(
        cls,
        value: tuple[Posture, ...] | None,
    ) -> tuple[Posture, ...] | None:
        if value is None:
            return None
        canonical = _canonical_postures(value)
        if not canonical:
            raise ValueError(
                "permitted_postures must be non-empty when set; "
                "use null/None to indicate no constraint",
            )
        return canonical

Manifest

Bases: BaseModel

A collection of tool definitions keyed by tool name.

A manifest is the policy document for a runtime configuration. Every tool the LLM can call must appear here; calls to undeclared tools fail closed in the classifier.

Attributes:

Name Type Description
tools dict[str, ToolDefinition]

Mapping from tool name to its :class:ToolDefinition. Each definition's name field must match its key in this mapping. Empty manifests are permitted (zero tools declared).

Examples:

>>> manifest = Manifest(tools={
...     "read_file": ToolDefinition(name="read_file", effects=(Effect.READ,)),
... })
>>> manifest.get("read_file").effects
(<Effect.READ: 'read'>,)
Source code in src/spine_lite/manifest.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class Manifest(BaseModel):
    """A collection of tool definitions keyed by tool name.

    A manifest is the policy document for a runtime configuration. Every
    tool the LLM can call must appear here; calls to undeclared tools
    fail closed in the classifier.

    Attributes:
        tools: Mapping from tool name to its :class:`ToolDefinition`.
            Each definition's ``name`` field must match its key in this
            mapping. Empty manifests are permitted (zero tools declared).

    Examples:
        >>> manifest = Manifest(tools={
        ...     "read_file": ToolDefinition(name="read_file", effects=(Effect.READ,)),
        ... })
        >>> manifest.get("read_file").effects
        (<Effect.READ: 'read'>,)
    """

    model_config: ClassVar[ConfigDict] = ConfigDict(
        frozen=True,
        extra="forbid",
        validate_default=True,
    )

    tools: dict[str, ToolDefinition] = Field(default_factory=dict)

    @field_validator("tools", mode="after")
    @classmethod
    def _names_match_keys(
        cls,
        tools: dict[str, ToolDefinition],
    ) -> dict[str, ToolDefinition]:
        for key, tool in tools.items():
            if tool.name != key:
                raise ValueError(
                    f"tool name mismatch: key {key!r} does not match definition name {tool.name!r}",
                )
        return tools

    def get(self, name: str) -> ToolDefinition:
        """Return the definition for ``name``.

        Args:
            name: Tool name to look up.

        Returns:
            The matching :class:`ToolDefinition`.

        Raises:
            ManifestError: If no tool with that name is declared.
        """
        try:
            return self.tools[name]
        except KeyError as exc:
            raise ManifestError(
                f"tool {name!r} not declared in manifest",
            ) from exc

get

get(name: str) -> ToolDefinition

Return the definition for name.

Parameters:

Name Type Description Default
name str

Tool name to look up.

required

Returns:

Type Description
ToolDefinition

The matching :class:ToolDefinition.

Raises:

Type Description
ManifestError

If no tool with that name is declared.

Source code in src/spine_lite/manifest.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def get(self, name: str) -> ToolDefinition:
    """Return the definition for ``name``.

    Args:
        name: Tool name to look up.

    Returns:
        The matching :class:`ToolDefinition`.

    Raises:
        ManifestError: If no tool with that name is declared.
    """
    try:
        return self.tools[name]
    except KeyError as exc:
        raise ManifestError(
            f"tool {name!r} not declared in manifest",
        ) from exc

parse_manifest

parse_manifest(data: Any) -> Manifest

Validate data as a :class:Manifest.

Wraps pydantic's :class:pydantic.ValidationError as :class:ManifestError so callers can catch a single typed exception rooted at :class:SpineLiteError.

Parameters:

Name Type Description Default
data Any

A Python mapping (dict), a JSON string, or JSON bytes. Strings and bytes are parsed via :meth:pydantic.BaseModel.model_validate_json; everything else through :meth:pydantic.BaseModel.model_validate.

required

Returns:

Type Description
Manifest

A validated, immutable :class:Manifest.

Raises:

Type Description
ManifestError

If validation fails for any reason. The original :class:pydantic.ValidationError is attached as __cause__.

Examples:

>>> parse_manifest({
...     "tools": {
...         "read_file": {"name": "read_file", "effects": ["read"]},
...     },
... }).get("read_file").effects
(<Effect.READ: 'read'>,)
Source code in src/spine_lite/manifest.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def parse_manifest(data: Any) -> Manifest:
    """Validate ``data`` as a :class:`Manifest`.

    Wraps pydantic's :class:`pydantic.ValidationError` as
    :class:`ManifestError` so callers can catch a single typed exception
    rooted at :class:`SpineLiteError`.

    Args:
        data: A Python mapping (dict), a JSON string, or JSON bytes.
            Strings and bytes are parsed via
            :meth:`pydantic.BaseModel.model_validate_json`; everything
            else through :meth:`pydantic.BaseModel.model_validate`.

    Returns:
        A validated, immutable :class:`Manifest`.

    Raises:
        ManifestError: If validation fails for any reason. The original
            :class:`pydantic.ValidationError` is attached as ``__cause__``.

    Examples:
        >>> parse_manifest({
        ...     "tools": {
        ...         "read_file": {"name": "read_file", "effects": ["read"]},
        ...     },
        ... }).get("read_file").effects
        (<Effect.READ: 'read'>,)
    """
    try:
        if isinstance(data, (str, bytes)):
            return Manifest.model_validate_json(data)
        return Manifest.model_validate(data)
    except ValidationError as exc:
        raise ManifestError(f"manifest validation failed: {exc}") from exc

Classifier

spine_lite.classifier

Effect classifier.

The :func:classify function maps a :class:ToolCall and a validated :class:spine_lite.manifest.Manifest to a :class:Decision.

Pure module: deterministic, no I/O, no clocks, no randomness. Identical inputs produce identical decisions every time. The decision's rationale is the only string-formatted field, and it is built from fields in canonical order so two calls with the same inputs produce the same byte-for-byte rationale.

ToolCall dataclass

A planned tool invocation to classify.

Attributes:

Name Type Description
tool str

Tool name as declared in the manifest.

arguments dict[str, Any]

Free-form key/value arguments. Currently informational only; future phases may use them to refine classification beyond the manifest's declared effects.

Source code in src/spine_lite/classifier.py
24
25
26
27
28
29
30
31
32
33
34
35
36
@dataclass(frozen=True, slots=True, kw_only=True)
class ToolCall:
    """A planned tool invocation to classify.

    Attributes:
        tool: Tool name as declared in the manifest.
        arguments: Free-form key/value arguments. Currently informational
            only; future phases may use them to refine classification
            beyond the manifest's declared effects.
    """

    tool: str
    arguments: dict[str, Any] = field(default_factory=dict)

Decision dataclass

The result of classifying a :class:ToolCall.

Attributes:

Name Type Description
tool str

Echoed from the input call.

effects tuple[Effect, ...]

The full set of effect classes the call can produce, as a canonically-ordered tuple (sorted by PRECEDENCE). Tuple rather than frozenset so equality and serialisation are byte-stable.

most_restrictive Effect

The dominant effect under :data:spine_lite.PRECEDENCE. Always a member of effects.

rationale str

Human-readable explanation of why this effect set was chosen. Format is canonical so byte-stable across runs.

Source code in src/spine_lite/classifier.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@dataclass(frozen=True, slots=True, kw_only=True)
class Decision:
    """The result of classifying a :class:`ToolCall`.

    Attributes:
        tool: Echoed from the input call.
        effects: The full set of effect classes the call can produce, as a
            canonically-ordered tuple (sorted by ``PRECEDENCE``). Tuple
            rather than frozenset so equality and serialisation are
            byte-stable.
        most_restrictive: The dominant effect under
            :data:`spine_lite.PRECEDENCE`. Always a member of ``effects``.
        rationale: Human-readable explanation of why this effect set was
            chosen. Format is canonical so byte-stable across runs.
    """

    tool: str
    effects: tuple[Effect, ...]
    most_restrictive: Effect
    rationale: str

classify

classify(
    tool_call: ToolCall, manifest: Manifest
) -> Decision

Classify tool_call against manifest.

Parameters:

Name Type Description Default
tool_call ToolCall

The planned invocation.

required
manifest Manifest

A validated :class:Manifest declaring the tool.

required

Returns:

Name Type Description
A Decision

class:Decision carrying the effect set, the dominant effect,

Decision

and a deterministic rationale.

Raises:

Type Description
ManifestError

If the tool isn't declared in the manifest.

Examples:

>>> from spine_lite import Effect, Manifest, ToolDefinition
>>> manifest = Manifest(tools={
...     "fetch": ToolDefinition(
...         name="fetch",
...         effects=(Effect.NETWORK, Effect.READ),
...     ),
... })
>>> decision = classify(ToolCall(tool="fetch"), manifest)
>>> decision.most_restrictive
<Effect.NETWORK: 'network'>
>>> decision.effects
(<Effect.NETWORK: 'network'>, <Effect.READ: 'read'>)
Source code in src/spine_lite/classifier.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def classify(tool_call: ToolCall, manifest: Manifest) -> Decision:
    """Classify ``tool_call`` against ``manifest``.

    Args:
        tool_call: The planned invocation.
        manifest: A validated :class:`Manifest` declaring the tool.

    Returns:
        A :class:`Decision` carrying the effect set, the dominant effect,
        and a deterministic rationale.

    Raises:
        ManifestError: If the tool isn't declared in the manifest.

    Examples:
        >>> from spine_lite import Effect, Manifest, ToolDefinition
        >>> manifest = Manifest(tools={
        ...     "fetch": ToolDefinition(
        ...         name="fetch",
        ...         effects=(Effect.NETWORK, Effect.READ),
        ...     ),
        ... })
        >>> decision = classify(ToolCall(tool="fetch"), manifest)
        >>> decision.most_restrictive
        <Effect.NETWORK: 'network'>
        >>> decision.effects
        (<Effect.NETWORK: 'network'>, <Effect.READ: 'read'>)
    """
    definition = manifest.get(tool_call.tool)

    dominant = most_restrictive(definition.effects)
    return Decision(
        tool=tool_call.tool,
        effects=definition.effects,
        most_restrictive=dominant,
        rationale=_rationale(tool_call.tool, definition.effects, dominant),
    )

Posture

spine_lite.posture

Posture state machine.

Closed :class:Posture enum, the closed :class:Disposition enum, the explicit :func:transition rule table, and :func:evaluate — the pure policy function that maps (posture, definition, decision) to a disposition.

Pure module: deterministic, no I/O. Same input produces the same output, every time. The transition table and the evaluation rules are encoded inline; both are sensitive to changes and warrant project-level sign-off on extension.

Posture

Bases: StrEnum

Operational posture of the runtime.

Drives how the runtime treats tool calls. Closed enum: extending requires a project-level decision. The members and their string values are pinned by docs/concepts/posture-and-hooks.md.

Members

INTERACTIVE: Operator at the keyboard; ambiguous calls escalate. AUTONOMOUS: No operator in the loop; ambiguous calls fail closed. DRY_RUN: Classification only; non-READ effects don't fire. LOCKED: Refuse everything except explicit allow-listed read-only calls.

Source code in src/spine_lite/posture.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Posture(StrEnum):
    """Operational posture of the runtime.

    Drives how the runtime treats tool calls. Closed enum: extending
    requires a project-level decision. The members and their string
    values are pinned by ``docs/concepts/posture-and-hooks.md``.

    Members:
        INTERACTIVE: Operator at the keyboard; ambiguous calls escalate.
        AUTONOMOUS: No operator in the loop; ambiguous calls fail closed.
        DRY_RUN: Classification only; non-``READ`` effects don't fire.
        LOCKED: Refuse everything except explicit allow-listed read-only calls.
    """

    INTERACTIVE = "interactive"
    AUTONOMOUS = "autonomous"
    DRY_RUN = "dry_run"
    LOCKED = "locked"

Disposition

Bases: StrEnum

The outcome of evaluating a classified decision under a posture.

Members

ALLOW: Permitted; the tool may run as classified. DENY: Refused; the tool must not run. ESCALATE: Permitted only after operator confirmation. Only returned under :attr:Posture.INTERACTIVE; autonomous postures fail closed instead of escalating.

Source code in src/spine_lite/posture.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Disposition(StrEnum):
    """The outcome of evaluating a classified decision under a posture.

    Members:
        ALLOW: Permitted; the tool may run as classified.
        DENY: Refused; the tool must not run.
        ESCALATE: Permitted only after operator confirmation. Only
            returned under :attr:`Posture.INTERACTIVE`; autonomous
            postures fail closed instead of escalating.
    """

    ALLOW = "allow"
    DENY = "deny"
    ESCALATE = "escalate"

transition

transition(current: Posture, target: Posture) -> Posture

Transition from current to target.

Identity transitions (target is current) are always permitted and return current unchanged. Cross-posture transitions must appear in the allow-set for the source posture.

Parameters:

Name Type Description Default
current Posture

The posture being left.

required
target Posture

The posture being entered.

required

Returns:

Type Description
Posture

The new posture (always equal to target on success).

Raises:

Type Description
PostureError

If the transition is not in the allow-set.

Examples:

>>> transition(Posture.INTERACTIVE, Posture.AUTONOMOUS)
<Posture.AUTONOMOUS: 'autonomous'>
>>> transition(Posture.LOCKED, Posture.INTERACTIVE)
<Posture.INTERACTIVE: 'interactive'>
Source code in src/spine_lite/posture.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def transition(current: Posture, target: Posture) -> Posture:
    """Transition from ``current`` to ``target``.

    Identity transitions (``target is current``) are always permitted and
    return ``current`` unchanged. Cross-posture transitions must appear
    in the allow-set for the source posture.

    Args:
        current: The posture being left.
        target: The posture being entered.

    Returns:
        The new posture (always equal to ``target`` on success).

    Raises:
        PostureError: If the transition is not in the allow-set.

    Examples:
        >>> transition(Posture.INTERACTIVE, Posture.AUTONOMOUS)
        <Posture.AUTONOMOUS: 'autonomous'>
        >>> transition(Posture.LOCKED, Posture.INTERACTIVE)
        <Posture.INTERACTIVE: 'interactive'>
    """
    if target is current:
        return current
    if target not in _ALLOWED_TRANSITIONS[current]:
        raise PostureError(
            f"illegal posture transition: {current.value}{target.value}",
        )
    return target

evaluate

evaluate(
    posture: Posture,
    definition: ToolDefinition,
    decision: Decision,
) -> Disposition

Map a classified call under a posture to a :class:Disposition.

Pure function. Encodes the policy rules:

  1. Posture allow-list. If the tool's :attr:~spine_lite.manifest.ToolDefinition.permitted_postures is set and the current posture isn't in it, the tool is denied regardless of the rest.
  2. LOCKED. Only READ calls are permitted. Anything else is denied.
  3. DRY_RUN. Only READ calls fire. Anything else is denied (the tool would be classified, but DRY_RUN's contract is that no state-changing effect actually executes).
  4. require_confirmation. A tool flagged as requiring confirmation escalates under INTERACTIVE and fails closed (denies) under AUTONOMOUS.
  5. Otherwise: ALLOW.

Parameters:

Name Type Description Default
posture Posture

Current operational posture.

required
definition ToolDefinition

The tool's manifest definition.

required
decision Decision

The classifier's output for the tool call.

required

Returns:

Type Description
Disposition

ALLOW, DENY, or ESCALATE.

Examples:

>>> from spine_lite import Effect, ToolDefinition, Decision
>>> defn = ToolDefinition(name="t", effects=(Effect.WRITE,))
>>> dec = Decision(
...     tool="t", effects=(Effect.WRITE,),
...     most_restrictive=Effect.WRITE, rationale="",
... )
>>> evaluate(Posture.LOCKED, defn, dec)
<Disposition.DENY: 'deny'>
>>> evaluate(Posture.INTERACTIVE, defn, dec)
<Disposition.ALLOW: 'allow'>
Source code in src/spine_lite/posture.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def evaluate(
    posture: Posture,
    definition: ToolDefinition,
    decision: Decision,
) -> Disposition:
    """Map a classified call under a posture to a :class:`Disposition`.

    Pure function. Encodes the policy rules:

    1. **Posture allow-list.** If the tool's
       :attr:`~spine_lite.manifest.ToolDefinition.permitted_postures` is
       set and the current posture isn't in it, the tool is denied
       regardless of the rest.
    2. **LOCKED.** Only ``READ`` calls are permitted. Anything else is
       denied.
    3. **DRY_RUN.** Only ``READ`` calls fire. Anything else is denied
       (the tool would be classified, but DRY_RUN's contract is that no
       state-changing effect actually executes).
    4. **`require_confirmation`.** A tool flagged as requiring
       confirmation escalates under ``INTERACTIVE`` and fails closed
       (denies) under ``AUTONOMOUS``.
    5. Otherwise: ``ALLOW``.

    Args:
        posture: Current operational posture.
        definition: The tool's manifest definition.
        decision: The classifier's output for the tool call.

    Returns:
        ``ALLOW``, ``DENY``, or ``ESCALATE``.

    Examples:
        >>> from spine_lite import Effect, ToolDefinition, Decision
        >>> defn = ToolDefinition(name="t", effects=(Effect.WRITE,))
        >>> dec = Decision(
        ...     tool="t", effects=(Effect.WRITE,),
        ...     most_restrictive=Effect.WRITE, rationale="",
        ... )
        >>> evaluate(Posture.LOCKED, defn, dec)
        <Disposition.DENY: 'deny'>
        >>> evaluate(Posture.INTERACTIVE, defn, dec)
        <Disposition.ALLOW: 'allow'>
    """
    if definition.permitted_postures is not None and posture not in definition.permitted_postures:
        return Disposition.DENY

    if posture is Posture.LOCKED:
        return Disposition.ALLOW if decision.most_restrictive is Effect.READ else Disposition.DENY

    if posture is Posture.DRY_RUN:
        return Disposition.ALLOW if decision.most_restrictive is Effect.READ else Disposition.DENY

    if definition.require_confirmation:
        if posture is Posture.AUTONOMOUS:
            return Disposition.DENY
        return Disposition.ESCALATE

    return Disposition.ALLOW

Phase 3 modules

Stubs with phase-pinning docstrings. The reference expands as implementations land.

spine_lite.receipt

Decision receipts.

A :class:Receipt is a structured record of a single classified call under a posture. Receipts are content-addressable: serialising a receipt to its canonical JSON form produces byte-identical output across runs and platforms given identical inputs, and :meth:Receipt.content_hash hashes that canonical form with SHA-256.

Pure module: no clocks, no randomness, no I/O. Wall-clock metadata that needs to live alongside a receipt belongs in the hook layer; the receipt itself stays content-addressable.

Receipt dataclass

A deterministic record of one classified call under a posture.

Attributes:

Name Type Description
tool str

Name of the tool that was classified.

arguments dict[str, Any]

Arguments echoed from the input call. Stored as authored; canonical serialisation sorts keys.

effects tuple[Effect, ...]

Canonical effect tuple from the classifier (ordered by PRECEDENCE).

most_restrictive Effect

Dominant effect under PRECEDENCE.

rationale str

Byte-stable rationale from the classifier.

posture Posture

Operational posture at the time of evaluation.

disposition Disposition

ALLOW, DENY, or ESCALATE from :func:spine_lite.posture.evaluate.

require_confirmation bool

Echoed from the tool's manifest definition so receipts stay self-contained for replay.

Examples:

>>> from spine_lite import Effect, Posture, Disposition
>>> r = Receipt(
...     tool="t",
...     arguments={"a": 1},
...     effects=(Effect.READ,),
...     most_restrictive=Effect.READ,
...     rationale="...",
...     posture=Posture.INTERACTIVE,
...     disposition=Disposition.ALLOW,
...     require_confirmation=False,
... )
>>> len(r.content_hash())
64
Source code in src/spine_lite/receipt.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
@dataclass(frozen=True, slots=True, kw_only=True)
class Receipt:
    """A deterministic record of one classified call under a posture.

    Attributes:
        tool: Name of the tool that was classified.
        arguments: Arguments echoed from the input call. Stored as
            authored; canonical serialisation sorts keys.
        effects: Canonical effect tuple from the classifier (ordered by
            ``PRECEDENCE``).
        most_restrictive: Dominant effect under ``PRECEDENCE``.
        rationale: Byte-stable rationale from the classifier.
        posture: Operational posture at the time of evaluation.
        disposition: ``ALLOW``, ``DENY``, or ``ESCALATE`` from
            :func:`spine_lite.posture.evaluate`.
        require_confirmation: Echoed from the tool's manifest definition
            so receipts stay self-contained for replay.

    Examples:
        >>> from spine_lite import Effect, Posture, Disposition
        >>> r = Receipt(
        ...     tool="t",
        ...     arguments={"a": 1},
        ...     effects=(Effect.READ,),
        ...     most_restrictive=Effect.READ,
        ...     rationale="...",
        ...     posture=Posture.INTERACTIVE,
        ...     disposition=Disposition.ALLOW,
        ...     require_confirmation=False,
        ... )
        >>> len(r.content_hash())
        64
    """

    tool: str
    arguments: dict[str, Any]
    effects: tuple[Effect, ...]
    most_restrictive: Effect
    rationale: str
    posture: Posture
    disposition: Disposition
    require_confirmation: bool

    def to_canonical_dict(self) -> dict[str, Any]:
        """Return a canonical dict representation.

        Keys are sorted; enum values are serialised as their string
        forms; the effect tuple is serialised as a list. Suitable as
        input to :func:`json.dumps` with ``sort_keys=True`` for byte-
        stable output.

        Returns:
            A dict whose JSON encoding is byte-stable across runs.
        """
        return {
            "arguments": self.arguments,
            "disposition": self.disposition.value,
            "effects": [e.value for e in self.effects],
            "most_restrictive": self.most_restrictive.value,
            "posture": self.posture.value,
            "rationale": self.rationale,
            "require_confirmation": self.require_confirmation,
            "tool": self.tool,
        }

    def to_canonical_json(self) -> str:
        """Return a byte-stable JSON encoding.

        Uses ``sort_keys=True``, ``ensure_ascii=False`` (so the rationale
        stays human-readable in its original encoding), and a compact
        separator pair. Two receipts with identical fields produce
        byte-identical output.
        """
        return json.dumps(
            self.to_canonical_dict(),
            sort_keys=True,
            ensure_ascii=False,
            separators=(",", ":"),
        )

    def content_hash(self) -> str:
        """Return the SHA-256 hex digest of :meth:`to_canonical_json`.

        Identical receipts produce identical hashes. Different receipts —
        even ones differing only in argument order before canonicalisation
        — produce identical hashes once round-tripped through this method.
        """
        encoded = self.to_canonical_json().encode("utf-8")
        return hashlib.sha256(encoded).hexdigest()

to_canonical_dict

to_canonical_dict() -> dict[str, Any]

Return a canonical dict representation.

Keys are sorted; enum values are serialised as their string forms; the effect tuple is serialised as a list. Suitable as input to :func:json.dumps with sort_keys=True for byte- stable output.

Returns:

Type Description
dict[str, Any]

A dict whose JSON encoding is byte-stable across runs.

Source code in src/spine_lite/receipt.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def to_canonical_dict(self) -> dict[str, Any]:
    """Return a canonical dict representation.

    Keys are sorted; enum values are serialised as their string
    forms; the effect tuple is serialised as a list. Suitable as
    input to :func:`json.dumps` with ``sort_keys=True`` for byte-
    stable output.

    Returns:
        A dict whose JSON encoding is byte-stable across runs.
    """
    return {
        "arguments": self.arguments,
        "disposition": self.disposition.value,
        "effects": [e.value for e in self.effects],
        "most_restrictive": self.most_restrictive.value,
        "posture": self.posture.value,
        "rationale": self.rationale,
        "require_confirmation": self.require_confirmation,
        "tool": self.tool,
    }

to_canonical_json

to_canonical_json() -> str

Return a byte-stable JSON encoding.

Uses sort_keys=True, ensure_ascii=False (so the rationale stays human-readable in its original encoding), and a compact separator pair. Two receipts with identical fields produce byte-identical output.

Source code in src/spine_lite/receipt.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def to_canonical_json(self) -> str:
    """Return a byte-stable JSON encoding.

    Uses ``sort_keys=True``, ``ensure_ascii=False`` (so the rationale
    stays human-readable in its original encoding), and a compact
    separator pair. Two receipts with identical fields produce
    byte-identical output.
    """
    return json.dumps(
        self.to_canonical_dict(),
        sort_keys=True,
        ensure_ascii=False,
        separators=(",", ":"),
    )

content_hash

content_hash() -> str

Return the SHA-256 hex digest of :meth:to_canonical_json.

Identical receipts produce identical hashes. Different receipts — even ones differing only in argument order before canonicalisation — produce identical hashes once round-tripped through this method.

Source code in src/spine_lite/receipt.py
106
107
108
109
110
111
112
113
114
def content_hash(self) -> str:
    """Return the SHA-256 hex digest of :meth:`to_canonical_json`.

    Identical receipts produce identical hashes. Different receipts —
    even ones differing only in argument order before canonicalisation
    — produce identical hashes once round-tripped through this method.
    """
    encoded = self.to_canonical_json().encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()

spine_lite.hook

Claude Code PreToolUse hook adapter.

The thin I/O wrapper around the pure pipeline. Reads a JSON payload from stdin, classifies the tool call against the configured manifest, applies the posture state machine, builds a deterministic receipt, and writes the receipt's canonical JSON form to stdout. Exit code signals the disposition.

This is the only module in the package besides :mod:spine_lite.cli that touches stdin, stdout, or wall-clock time. Everything inside is pure.

run_hook

run_hook(
    manifest: Manifest,
    payload: str | bytes,
    *,
    posture: Posture = Posture.INTERACTIVE,
) -> tuple[Receipt, int]

Execute the pipeline against payload under manifest.

The payload is expected to be a JSON object with at least a tool field (string) and optionally an arguments field (object). Any other top-level keys are ignored; the hook contract is intentionally minimal so it can adapt to multiple host hook formats.

Parameters:

Name Type Description Default
manifest Manifest

A validated manifest declaring every tool the host may invoke.

required
payload str | bytes

The raw stdin bytes or string from the host.

required
posture Posture

The current operational posture. Defaults to INTERACTIVE; production setups should pass an explicit value via the CLI --posture flag.

INTERACTIVE

Returns:

Type Description
Receipt

A tuple of (receipt, exit_code). The caller is responsible

int

for writing the receipt to stdout and returning the exit code.

Raises:

Type Description
HookError

If the payload is not valid JSON, is not a JSON object, is missing required fields, or has wrong-typed fields.

ManifestError

If the requested tool is not declared in the manifest. Propagated unchanged from the classifier.

Source code in src/spine_lite/hook.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def run_hook(
    manifest: Manifest,
    payload: str | bytes,
    *,
    posture: Posture = Posture.INTERACTIVE,
) -> tuple[Receipt, int]:
    """Execute the pipeline against ``payload`` under ``manifest``.

    The payload is expected to be a JSON object with at least a ``tool``
    field (string) and optionally an ``arguments`` field (object). Any
    other top-level keys are ignored; the hook contract is intentionally
    minimal so it can adapt to multiple host hook formats.

    Args:
        manifest: A validated manifest declaring every tool the host
            may invoke.
        payload: The raw stdin bytes or string from the host.
        posture: The current operational posture. Defaults to
            ``INTERACTIVE``; production setups should pass an explicit
            value via the CLI ``--posture`` flag.

    Returns:
        A tuple of ``(receipt, exit_code)``. The caller is responsible
        for writing the receipt to stdout and returning the exit code.

    Raises:
        HookError: If the payload is not valid JSON, is not a JSON
            object, is missing required fields, or has wrong-typed
            fields.
        ManifestError: If the requested tool is not declared in the
            manifest. Propagated unchanged from the classifier.
    """
    data = _parse_payload(payload)

    tool_name = data.get("tool")
    if not isinstance(tool_name, str) or not tool_name:
        raise HookError("payload missing 'tool' string field")

    arguments = data.get("arguments", {})
    if not isinstance(arguments, dict):
        raise HookError("'arguments' field must be a JSON object if present")

    decision = classify(ToolCall(tool=tool_name, arguments=arguments), manifest)
    definition = manifest.get(tool_name)
    disposition = evaluate(posture, definition, decision)

    receipt = Receipt(
        tool=tool_name,
        arguments=arguments,
        effects=decision.effects,
        most_restrictive=decision.most_restrictive,
        rationale=decision.rationale,
        posture=posture,
        disposition=disposition,
        require_confirmation=definition.require_confirmation,
    )

    return receipt, _DISPOSITION_EXIT[disposition]

main

main(
    manifest: Manifest,
    *,
    stdin: IO[str] = sys.stdin,
    stdout: IO[str] = sys.stdout,
    stderr: IO[str] = sys.stderr,
    posture: Posture = Posture.INTERACTIVE,
) -> int

End-to-end hook invocation.

Reads stdin, runs :func:run_hook, writes the canonical receipt JSON to stdout, and returns the exit code. On error, writes a structured JSON error payload to stdout (so the host always parses a JSON object) and a one-line message to stderr.

Parameters:

Name Type Description Default
manifest Manifest

A validated manifest.

required
stdin IO[str]

Input stream. Defaults to :data:sys.stdin.

stdin
stdout IO[str]

Output stream for the receipt or error JSON. Defaults to :data:sys.stdout.

stdout
stderr IO[str]

Output stream for human-readable error messages. Defaults to :data:sys.stderr.

stderr
posture Posture

Operational posture.

INTERACTIVE

Returns:

Type Description
int

Exit code per the contract:

int
  • 0 (EXIT_ALLOW) — disposition is ALLOW.
int
  • 1 (EXIT_DENY) — disposition is DENY.
int
  • 2 (EXIT_ESCALATE) — disposition is ESCALATE.
int
  • 64 (EXIT_HOOK_ERROR) — payload protocol violation.
int
  • 65 (EXIT_MANIFEST_ERROR) — tool not declared in manifest.
Source code in src/spine_lite/hook.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def main(
    manifest: Manifest,
    *,
    stdin: IO[str] = sys.stdin,
    stdout: IO[str] = sys.stdout,
    stderr: IO[str] = sys.stderr,
    posture: Posture = Posture.INTERACTIVE,
) -> int:
    """End-to-end hook invocation.

    Reads stdin, runs :func:`run_hook`, writes the canonical receipt
    JSON to stdout, and returns the exit code. On error, writes a
    structured JSON error payload to stdout (so the host always parses
    a JSON object) and a one-line message to stderr.

    Args:
        manifest: A validated manifest.
        stdin: Input stream. Defaults to :data:`sys.stdin`.
        stdout: Output stream for the receipt or error JSON. Defaults
            to :data:`sys.stdout`.
        stderr: Output stream for human-readable error messages.
            Defaults to :data:`sys.stderr`.
        posture: Operational posture.

    Returns:
        Exit code per the contract:

        - ``0`` (``EXIT_ALLOW``) — disposition is ALLOW.
        - ``1`` (``EXIT_DENY``) — disposition is DENY.
        - ``2`` (``EXIT_ESCALATE``) — disposition is ESCALATE.
        - ``64`` (``EXIT_HOOK_ERROR``) — payload protocol violation.
        - ``65`` (``EXIT_MANIFEST_ERROR``) — tool not declared in manifest.
    """
    payload = stdin.read()
    try:
        receipt, exit_code = run_hook(manifest, payload, posture=posture)
        stdout.write(receipt.to_canonical_json())
        stdout.write("\n")
        return exit_code
    except ManifestError as exc:
        _write_error(stdout, stderr, "ManifestError", str(exc))
        return EXIT_MANIFEST_ERROR
    except HookError as exc:
        _write_error(stdout, stderr, "HookError", str(exc))
        return EXIT_HOOK_ERROR

CLI module

spine_lite.cli

Operator command-line interface.

Phase 3 ships the full subcommand surface: version, validate-manifest, classify, hook. The CLI is one of the two modules in the package permitted to do I/O (the other is :mod:spine_lite.hook); everything below it is pure.

app module-attribute

app = Typer(
    name="spine-lite",
    help="Deterministic policy and effects runtime for LLM tool calls.",
    no_args_is_help=True,
    add_completion=False,
)

version

version() -> None

Print the installed spine-lite version and exit.

Source code in src/spine_lite/cli.py
50
51
52
53
@app.command()
def version() -> None:
    """Print the installed spine-lite version and exit."""
    typer.echo(__version__)

See also