statespec 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
statespec/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Declarative state-machine specs with a generic enforcement engine.
2
+
3
+ A *spec* is a plain-data description of an entity's lifecycle: its states,
4
+ the named transitions between them, who (which roles) may fire each
5
+ transition, and the guard predicates that must hold. The same artifact is:
6
+
7
+ - **human-readable** — `render.to_mermaid` / `render.to_table` turn it into a
8
+ diagram and a plain-English table a non-engineer can sign off on;
9
+ - **machine-checkable** — `core.validate` proves the graph is coherent
10
+ (no unreachable or stuck states) and `core.apply` is the single, generic
11
+ interpreter every service-layer transition goes through, so the code
12
+ *cannot* drift from the spec without a test noticing.
13
+
14
+ The engine here is deliberately domain-agnostic: it knows nothing about
15
+ any domain. Domain specs live in the consuming application, next to the
16
+ slice they govern.
17
+ """
18
+
19
+ from statespec.core import (
20
+ Decision,
21
+ Evaluation,
22
+ ExpressionError,
23
+ GuardRejected,
24
+ IllegalTransition,
25
+ Invariant,
26
+ InvariantViolation,
27
+ PermissionDenied,
28
+ StateSpec,
29
+ Transition,
30
+ TransitionError,
31
+ UnknownAction,
32
+ apply,
33
+ can_fire,
34
+ enabled_transitions,
35
+ fire,
36
+ validate,
37
+ )
38
+
39
+ __all__ = [
40
+ "Decision",
41
+ "Evaluation",
42
+ "ExpressionError",
43
+ "GuardRejected",
44
+ "IllegalTransition",
45
+ "Invariant",
46
+ "InvariantViolation",
47
+ "PermissionDenied",
48
+ "StateSpec",
49
+ "Transition",
50
+ "TransitionError",
51
+ "UnknownAction",
52
+ "apply",
53
+ "can_fire",
54
+ "enabled_transitions",
55
+ "fire",
56
+ "validate",
57
+ ]
statespec/core.py ADDED
@@ -0,0 +1,401 @@
1
+ """The generic state-machine engine: data model, enforcement, and analysis.
2
+
3
+ Nothing in here is invoice-specific. A `StateSpec` is pure data; `apply` is
4
+ the only place a transition is ever allowed to happen; `validate` proves a
5
+ spec is well-formed before it can mislead anyone. Keep this file small and
6
+ domain-free — that is what lets one Hypothesis test (driving `apply` over
7
+ random sequences) stand in as a correctness proof for *every* spec.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import deque
13
+ from dataclasses import dataclass
14
+ from typing import Mapping
15
+
16
+ from statespec import expr as _expr
17
+ from statespec.expr import ExpressionError # re-export for callers
18
+
19
+
20
+ # --- Data model -------------------------------------------------------------
21
+ #
22
+ # Guards and invariants are declarative expressions (see app/statespec/expr.py)
23
+ # — pure data, so the whole StateSpec serialises, renders, and diffs. The same
24
+ # object evaluates at runtime, so the rendered policy *is* the enforced policy.
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Transition:
29
+ """A named, role-gated edge from one or more source states to a dest."""
30
+
31
+ name: str
32
+ sources: tuple[str, ...]
33
+ dest: str
34
+ # Roles permitted to fire this transition. Empty = no one (a deliberate
35
+ # dead edge is a spec error; `validate` flags it).
36
+ roles: frozenset[str] = frozenset()
37
+ # Optional guard expression that must evaluate True against the context.
38
+ guard: object | None = None # an expr condition (Compare/All/Any/Not/Opaque)
39
+ # Plain-English description for the human-readable render.
40
+ label: str = ""
41
+ # Stable, namespaced control id for audit/compliance references (e.g.
42
+ # "BATCH-APPROVE"). Defaults to the action name; survives a label reword.
43
+ control_id: str | None = None
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Invariant:
48
+ name: str
49
+ condition: object # an expr condition; must hold in every reachable state
50
+ label: str = ""
51
+ control_id: str | None = None # stable control id (defaults to name)
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class StateSpec:
56
+ """A complete lifecycle: states, the context field schema, transitions,
57
+ and invariants."""
58
+
59
+ name: str
60
+ title: str
61
+ # state id -> human description
62
+ states: Mapping[str, str]
63
+ # context field name -> type tag (the contract a service snapshot must
64
+ # satisfy; the basis for validate's field/type checks). See expr.TYPE_TAGS.
65
+ fields: Mapping[str, str]
66
+ initial: str
67
+ terminal: frozenset[str]
68
+ transitions: tuple[Transition, ...]
69
+ invariants: tuple[Invariant, ...] = ()
70
+ # Author-asserted policy version. Bump when the *semantic* digest changes;
71
+ # the (version, semantic_digest) pair is the policy's behavioural identity. A
72
+ # wording-only edit moves presentation_digest, not this. See statespec.identity.
73
+ version: int = 1
74
+
75
+ def transition(self, action: str) -> Transition | None:
76
+ for t in self.transitions:
77
+ if t.name == action:
78
+ return t
79
+ return None
80
+
81
+
82
+ # --- Enforcement errors -----------------------------------------------------
83
+
84
+
85
+ class TransitionError(Exception):
86
+ """Base for every reason a transition can be refused."""
87
+
88
+
89
+ class UnknownAction(TransitionError):
90
+ """The action name isn't defined in the spec at all."""
91
+
92
+
93
+ class IllegalTransition(TransitionError):
94
+ """The action exists but not from the entity's current state."""
95
+
96
+
97
+ class PermissionDenied(TransitionError):
98
+ """The actor's roles don't include any role permitted to fire it."""
99
+
100
+
101
+ class GuardRejected(TransitionError):
102
+ """The action's guard predicate returned False for this entity."""
103
+
104
+
105
+ class InvariantViolation(Exception):
106
+ """A transition would produce a state that violates a declared invariant.
107
+
108
+ Deliberately NOT a TransitionError: invariants are *consequences* of guards
109
+ (a backstop), so this can only fire from a guard/spec bug or an out-of-band
110
+ mutation. It maps to HTTP 500 (an internal breach of a stated guarantee),
111
+ not a client 4xx. Any client-visible rule must be a guard, never a
112
+ standalone invariant."""
113
+
114
+ def __init__(self, dest: str, violated: list[str]):
115
+ self.dest = dest
116
+ self.violated = violated
117
+ super().__init__(
118
+ f"transition into {dest!r} would violate invariant(s): {violated}"
119
+ )
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Decision:
124
+ """The outcome of a `can_fire` check — allowed, or why not."""
125
+
126
+ allowed: bool
127
+ dest: str | None = None
128
+ reason: str = ""
129
+ error: type[TransitionError] | None = None
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Evaluation:
134
+ """Structured evidence that one condition was evaluated during a transition
135
+ — the audit record's "why was this allowed". `inputs` is just the fields the
136
+ expression referenced (not the whole entity)."""
137
+
138
+ control_id: str
139
+ kind: str # "guard" | "invariant"
140
+ expression: dict # expr.to_dict — the condition as data
141
+ result: bool
142
+ inputs: Mapping[str, object]
143
+
144
+
145
+ # --- The single enforcement path -------------------------------------------
146
+
147
+
148
+ def can_fire(
149
+ spec: StateSpec,
150
+ action: str,
151
+ current_state: str,
152
+ actor_roles: frozenset[str],
153
+ entity: Mapping[str, object] | None = None,
154
+ ) -> Decision:
155
+ """Pure decision function. Never mutates anything, and never raises
156
+ TransitionError — refusals come back as a Decision. It can raise
157
+ ExpressionError, for a malformed guard or a missing context field:
158
+ that's a contract/spec bug, not a refusal.
159
+
160
+ Checks, in order: action exists -> reachable from current state ->
161
+ actor permitted -> guard holds. The ordering is deliberate and matches
162
+ the error precedence callers (and tests) rely on.
163
+ """
164
+ t = spec.transition(action)
165
+ if t is None:
166
+ return Decision(False, reason=f"unknown action {action!r}", error=UnknownAction)
167
+ if current_state not in t.sources:
168
+ return Decision(
169
+ False,
170
+ reason=f"{action!r} not allowed from state {current_state!r}",
171
+ error=IllegalTransition,
172
+ )
173
+ if not (actor_roles & t.roles):
174
+ return Decision(
175
+ False,
176
+ reason=f"none of roles {set(actor_roles)} may fire {action!r}",
177
+ error=PermissionDenied,
178
+ )
179
+ if t.guard is not None:
180
+ # The guard is an expr condition. A malformed guard / missing context
181
+ # field raises ExpressionError (a contract bug) rather than a refusal.
182
+ if not t.guard.evaluate(entity or {}):
183
+ return Decision(
184
+ False,
185
+ reason=f"guard {_expr.render(t.guard)} rejected {action!r}",
186
+ error=GuardRejected,
187
+ )
188
+ return Decision(True, dest=t.dest, reason="ok")
189
+
190
+
191
+ def _require_fields(spec: StateSpec, ctx: Mapping[str, object]) -> None:
192
+ missing = set(spec.fields) - set(ctx)
193
+ if missing:
194
+ raise ExpressionError(
195
+ f"context for {spec.name!r} is missing field(s): {sorted(missing)}"
196
+ )
197
+
198
+
199
+ def fire(
200
+ spec: StateSpec,
201
+ action: str,
202
+ current_state: str,
203
+ actor_roles: frozenset[str],
204
+ entity: Mapping[str, object] | None = None,
205
+ *,
206
+ post_overrides: Mapping[str, object] | None = None,
207
+ ) -> tuple[str, list[Evaluation]]:
208
+ """Enforce a transition and return `(dest, evaluations)` — the destination
209
+ state plus structured evidence of every condition evaluated (the guard and
210
+ each invariant, with the inputs it read). The single mutation-decision path;
211
+ `apply` is the dest-only convenience wrapper.
212
+
213
+ After the transition is judged legal (action / source / role / guard), the
214
+ invariants are evaluated against the proposed post-state and the transition
215
+ is refused (InvariantViolation) if any fails. Proposed post-state is
216
+ `{**entity, status: dest, **post_overrides}`.
217
+ """
218
+ decision = can_fire(spec, action, current_state, actor_roles, entity)
219
+ if not decision.allowed:
220
+ assert decision.error is not None
221
+ raise decision.error(decision.reason)
222
+ assert decision.dest is not None
223
+ dest = decision.dest
224
+ ctx = entity or {}
225
+ t = spec.transition(action)
226
+ evaluations: list[Evaluation] = []
227
+
228
+ if t.guard is not None:
229
+ evaluations.append(
230
+ Evaluation(
231
+ control_id=t.control_id or t.name,
232
+ kind="guard",
233
+ expression=_expr.to_dict(t.guard),
234
+ result=True, # can_fire allowed => the guard held
235
+ inputs={f: ctx[f] for f in t.guard.fields() if f in ctx},
236
+ )
237
+ )
238
+
239
+ post = {**ctx, "status": dest, **(post_overrides or {})}
240
+ _require_fields(spec, post)
241
+ _expr.validate_context(spec.fields, post) # runtime values match declared types
242
+ violated: list[str] = []
243
+ for inv in spec.invariants:
244
+ ok = bool(inv.condition.evaluate(post))
245
+ evaluations.append(
246
+ Evaluation(
247
+ control_id=inv.control_id or inv.name,
248
+ kind="invariant",
249
+ expression=_expr.to_dict(inv.condition),
250
+ result=ok,
251
+ inputs={f: post[f] for f in inv.condition.fields() if f in post},
252
+ )
253
+ )
254
+ if not ok:
255
+ violated.append(inv.name)
256
+ if violated:
257
+ raise InvariantViolation(dest, violated)
258
+ return dest, evaluations
259
+
260
+
261
+ def apply(
262
+ spec: StateSpec,
263
+ action: str,
264
+ current_state: str,
265
+ actor_roles: frozenset[str],
266
+ entity: Mapping[str, object] | None = None,
267
+ *,
268
+ post_overrides: Mapping[str, object] | None = None,
269
+ ) -> str:
270
+ """Enforce a transition and return the destination state, or raise. The
271
+ dest-only wrapper around `fire` for callers that don't need the evidence."""
272
+ dest, _ = fire(
273
+ spec, action, current_state, actor_roles, entity, post_overrides=post_overrides
274
+ )
275
+ return dest
276
+
277
+
278
+ def enabled_transitions(
279
+ spec: StateSpec,
280
+ current_state: str,
281
+ actor_roles: frozenset[str],
282
+ entity: Mapping[str, object] | None = None,
283
+ ) -> list[Transition]:
284
+ """Every transition the given actor could fire right now."""
285
+ return [
286
+ t
287
+ for t in spec.transitions
288
+ if can_fire(spec, t.name, current_state, actor_roles, entity).allowed
289
+ ]
290
+
291
+
292
+ # --- Static analysis (well-formedness) -------------------------------------
293
+
294
+
295
+ def reachable_states(spec: StateSpec) -> set[str]:
296
+ """States reachable from `initial` by following transitions (roles/guards
297
+ ignored — this is structural reachability)."""
298
+ seen = {spec.initial}
299
+ queue: deque[str] = deque([spec.initial])
300
+ while queue:
301
+ s = queue.popleft()
302
+ for t in spec.transitions:
303
+ if s in t.sources and t.dest not in seen:
304
+ seen.add(t.dest)
305
+ queue.append(t.dest)
306
+ return seen
307
+
308
+
309
+ def _can_reach_terminal(spec: StateSpec, start: str) -> bool:
310
+ if not spec.terminal:
311
+ return True # nothing to reach; treated as vacuously fine
312
+ seen = {start}
313
+ queue: deque[str] = deque([start])
314
+ while queue:
315
+ s = queue.popleft()
316
+ if s in spec.terminal:
317
+ return True
318
+ for t in spec.transitions:
319
+ if s in t.sources and t.dest not in seen:
320
+ seen.add(t.dest)
321
+ queue.append(t.dest)
322
+ return False
323
+
324
+
325
+ def validate(
326
+ spec: StateSpec, known_roles: frozenset[str] | None = None
327
+ ) -> list[str]:
328
+ """Return a list of human-readable problems. Empty list == well-formed.
329
+
330
+ A well-formed spec has: a declared initial state; declared, terminal-only
331
+ sink states; every state reachable from initial; every non-terminal state
332
+ able to reach some terminal (no traps); unique transition + invariant
333
+ names; valid field-schema type tags; and guard/invariant expressions that
334
+ type-check against the field schema (every referenced field declared, every
335
+ comparison type-compatible, every opaque registered).
336
+
337
+ If `known_roles` is supplied (the application's role catalogue), every
338
+ transition's roles must be drawn from it — this catches a misspelled role
339
+ (e.g. `aprover`) that no user could ever hold, which would otherwise make
340
+ the transition silently un-fireable.
341
+ """
342
+ problems: list[str] = []
343
+ states = set(spec.states)
344
+
345
+ if spec.initial not in states:
346
+ problems.append(f"initial state {spec.initial!r} is not declared")
347
+ for term in spec.terminal:
348
+ if term not in states:
349
+ problems.append(f"terminal state {term!r} is not declared")
350
+
351
+ for fname, tag in spec.fields.items():
352
+ if tag not in _expr.TYPE_TAGS:
353
+ problems.append(f"field {fname!r} has unknown type tag {tag!r}")
354
+
355
+ seen_actions: set[str] = set()
356
+ for t in spec.transitions:
357
+ if t.name in seen_actions:
358
+ problems.append(f"duplicate transition name {t.name!r}")
359
+ seen_actions.add(t.name)
360
+ for s in t.sources:
361
+ if s not in states:
362
+ problems.append(f"transition {t.name!r} has undeclared source {s!r}")
363
+ if t.dest not in states:
364
+ problems.append(f"transition {t.name!r} has undeclared dest {t.dest!r}")
365
+ if any(src in spec.terminal for src in t.sources):
366
+ problems.append(
367
+ f"transition {t.name!r} leaves terminal state(s) "
368
+ f"{set(t.sources) & spec.terminal} — terminals must be sinks"
369
+ )
370
+ if not t.roles:
371
+ problems.append(f"transition {t.name!r} has no permitted roles (dead edge)")
372
+ if known_roles is not None and (t.roles - known_roles):
373
+ problems.append(
374
+ f"transition {t.name!r} references role(s) not in the catalogue: "
375
+ f"{sorted(t.roles - known_roles)}"
376
+ )
377
+ if t.guard is not None:
378
+ problems += [
379
+ f"transition {t.name!r} guard: {p}"
380
+ for p in _expr.typecheck(t.guard, spec.fields)
381
+ ]
382
+
383
+ seen_invariants: set[str] = set()
384
+ for inv in spec.invariants:
385
+ if inv.name in seen_invariants:
386
+ problems.append(f"duplicate invariant name {inv.name!r}")
387
+ seen_invariants.add(inv.name)
388
+ problems += [
389
+ f"invariant {inv.name!r}: {p}"
390
+ for p in _expr.typecheck(inv.condition, spec.fields)
391
+ ]
392
+
393
+ reachable = reachable_states(spec)
394
+ for s in states - reachable:
395
+ problems.append(f"state {s!r} is unreachable from initial {spec.initial!r}")
396
+
397
+ for s in states - spec.terminal:
398
+ if not _can_reach_terminal(spec, s):
399
+ problems.append(f"state {s!r} cannot reach any terminal state (trap)")
400
+
401
+ return problems