ccp-protocol 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.
ccp/model.py ADDED
@@ -0,0 +1,187 @@
1
+ """Protocol data models: Manifest, Grant, Signal, Decision."""
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+
5
+ from . import canonical, crypto
6
+ from .errors import AUTHORIZED, CONFIRMATION_REQUIRED, CCPError
7
+
8
+
9
+ @dataclass
10
+ class Decision:
11
+ """The outcome of submitting or confirming a signal."""
12
+
13
+ outcome: str
14
+ request_id: str | None = None
15
+ pending_id: str | None = None
16
+ result: object = None
17
+ error: str | None = None
18
+ detail: str | None = None
19
+ state: str | None = None
20
+
21
+ @property
22
+ def authorized(self):
23
+ return self.outcome == AUTHORIZED
24
+
25
+ @property
26
+ def needs_confirmation(self):
27
+ return self.outcome == CONFIRMATION_REQUIRED
28
+
29
+ @property
30
+ def rejected(self):
31
+ return self.outcome not in (AUTHORIZED, CONFIRMATION_REQUIRED)
32
+
33
+
34
+ class Manifest:
35
+ """A signed provider manifest (§8)."""
36
+
37
+ def __init__(self, document, data):
38
+ self.document = document
39
+ self.data = data
40
+ self._signing_input = canonical.signing_bytes(document)
41
+
42
+ @classmethod
43
+ def parse(cls, document):
44
+ data = canonical.parse_strict(document)
45
+ if not isinstance(data, dict):
46
+ raise CCPError("invalid_request", "manifest must be a JSON object")
47
+ for name in ("ccp", "provider", "version", "intents", "signature"):
48
+ if name not in data:
49
+ raise CCPError("invalid_request", "manifest missing %r" % (name,))
50
+ if not isinstance(data["provider"], dict) or "id" not in data["provider"]:
51
+ raise CCPError("invalid_request", "manifest provider.id is required")
52
+ if not isinstance(data["intents"], dict):
53
+ raise CCPError("invalid_request", "manifest intents must be an object")
54
+ return cls(document, data)
55
+
56
+ @property
57
+ def provider_id(self):
58
+ return self.data["provider"]["id"]
59
+
60
+ @property
61
+ def provider_name(self):
62
+ return self.data["provider"].get("name")
63
+
64
+ @property
65
+ def version(self):
66
+ return self.data["version"]
67
+
68
+ @property
69
+ def intents(self):
70
+ return self.data["intents"]
71
+
72
+ @property
73
+ def signature(self):
74
+ return self.data["signature"]
75
+
76
+ @property
77
+ def signing_input(self):
78
+ return self._signing_input
79
+
80
+ @property
81
+ def digest(self):
82
+ """`sha256:<hex>` over the canonical signing bytes (§26.1)."""
83
+ return canonical.digest_bytes(self._signing_input)
84
+
85
+ def intent_spec(self, name):
86
+ return self.intents.get(name)
87
+
88
+ def verify(self, expected_provider_id=None):
89
+ """Verify the manifest signature; raise CCPError on failure."""
90
+ if expected_provider_id is not None and self.provider_id != expected_provider_id:
91
+ raise CCPError("unauthenticated", "provider id does not match")
92
+ key = crypto.VerifyingKey.from_key_id(self.provider_id)
93
+ if not key.verify(self._signing_input, self.signature):
94
+ raise CCPError("unauthenticated", "manifest signature is invalid")
95
+ return True
96
+
97
+
98
+ @dataclass
99
+ class Grant:
100
+ """The consumer's local authorization record (§26.1)."""
101
+
102
+ id: str
103
+ requester_key: str
104
+ contact_id: str
105
+ manifest_digest: str
106
+ manifest_version: int
107
+ intents: list = field(default_factory=list)
108
+ limits: dict = field(default_factory=dict)
109
+ created_at: str = ""
110
+ expires_at: str = ""
111
+ confirmation: dict = field(default_factory=dict)
112
+ capabilities: list = field(default_factory=list)
113
+ placement: list = field(default_factory=list)
114
+ egress: dict = field(default_factory=dict)
115
+ state: str = "active"
116
+ state_reason: str | None = None
117
+
118
+ def is_active(self, now):
119
+ return self.state == "active" and now < self.expires_at
120
+
121
+ def pause(self, reason=None):
122
+ if self.state in ("revoked", "expired"):
123
+ raise CCPError("invalid_request", "cannot pause a %s grant" % self.state)
124
+ self.state = "paused"
125
+ self.state_reason = reason
126
+
127
+ def suspend(self, reason=None):
128
+ if self.state in ("revoked", "expired"):
129
+ raise CCPError("invalid_request", "cannot suspend a %s grant" % self.state)
130
+ self.state = "suspended"
131
+ self.state_reason = reason
132
+
133
+ def resume(self):
134
+ if self.state not in ("paused", "suspended"):
135
+ raise CCPError("invalid_request", "cannot resume a %s grant" % self.state)
136
+ self.state = "active"
137
+ self.state_reason = None
138
+
139
+ def revoke(self, reason=None):
140
+ self.state = "revoked"
141
+ self.state_reason = reason
142
+
143
+ def to_dict(self):
144
+ return asdict(self)
145
+
146
+ @classmethod
147
+ def from_dict(cls, data):
148
+ return cls(**data)
149
+
150
+
151
+ class Signal:
152
+ """A signed request to invoke one intent (§10.1)."""
153
+
154
+ FIELDS = ("ccp", "request_id", "grant_digest", "intent", "params",
155
+ "issued_at", "expires_at", "signature")
156
+
157
+ def __init__(self, document, data):
158
+ self.document = document
159
+ self.data = data
160
+ self._signing_input = canonical.signing_bytes(document)
161
+
162
+ @classmethod
163
+ def parse(cls, document):
164
+ data = canonical.parse_strict(document)
165
+ if not isinstance(data, dict):
166
+ raise CCPError("invalid_request", "signal must be a JSON object")
167
+ for name in cls.FIELDS:
168
+ if name not in data:
169
+ raise CCPError("invalid_request", "signal missing %r" % (name,))
170
+ return cls(document, data)
171
+
172
+ @property
173
+ def request_id(self):
174
+ return self.data["request_id"]
175
+
176
+ @property
177
+ def intent(self):
178
+ return self.data["intent"]
179
+
180
+ @property
181
+ def params(self):
182
+ return self.data["params"]
183
+
184
+ def verify(self, verifying_key):
185
+ if not verifying_key.verify(self._signing_input, self.data["signature"]):
186
+ raise CCPError("unauthenticated", "signal signature is invalid")
187
+ return True
ccp/requester.py ADDED
@@ -0,0 +1,66 @@
1
+ """Requester role: build and sign manifests and signals."""
2
+
3
+ from .errors import CCPError
4
+ from .signing import sign_document
5
+
6
+ DEFAULT_CCP = "0.4-draft"
7
+
8
+
9
+ def build_manifest(provider, version, intents, *, ccp=DEFAULT_CCP):
10
+ """Build the unsigned manifest mapping (§8)."""
11
+ if not isinstance(provider, dict) or not provider.get("id"):
12
+ raise CCPError("invalid_request", "provider.id is required")
13
+ return {
14
+ "ccp": ccp,
15
+ "provider": dict(provider),
16
+ "version": version,
17
+ "intents": intents,
18
+ }
19
+
20
+
21
+ def sign_manifest(provider, version, intents, signing_key, *,
22
+ ccp=DEFAULT_CCP):
23
+ """Return a signed manifest in canonical wire form."""
24
+ return sign_document(
25
+ build_manifest(provider, version, intents, ccp=ccp), signing_key)
26
+
27
+
28
+ def build_signal(request_id, grant_digest, intent, params, issued_at,
29
+ expires_at, *, ccp=DEFAULT_CCP):
30
+ """Build the unsigned signal mapping (§10.1)."""
31
+ return {
32
+ "ccp": ccp,
33
+ "request_id": request_id,
34
+ "grant_digest": grant_digest,
35
+ "intent": intent,
36
+ "params": params,
37
+ "issued_at": issued_at,
38
+ "expires_at": expires_at,
39
+ }
40
+
41
+
42
+ def sign_signal(request_id, grant_digest, intent, params, issued_at,
43
+ expires_at, signing_key, *, ccp=DEFAULT_CCP):
44
+ """Return a signed signal in canonical wire form."""
45
+ return sign_document(
46
+ build_signal(request_id, grant_digest, intent, params, issued_at,
47
+ expires_at, ccp=ccp),
48
+ signing_key,
49
+ )
50
+
51
+
52
+ class Requester:
53
+ """A signing identity that produces manifests and signals."""
54
+
55
+ def __init__(self, signing_key):
56
+ self.signing_key = signing_key
57
+ self.key_id = signing_key.key_id
58
+
59
+ def manifest(self, provider, version, intents, *, ccp=DEFAULT_CCP):
60
+ return sign_manifest(provider, version, intents, self.signing_key,
61
+ ccp=ccp)
62
+
63
+ def signal(self, request_id, grant_digest, intent, params, issued_at,
64
+ expires_at, *, ccp=DEFAULT_CCP):
65
+ return sign_signal(request_id, grant_digest, intent, params,
66
+ issued_at, expires_at, self.signing_key, ccp=ccp)
ccp/runtime.py ADDED
@@ -0,0 +1,200 @@
1
+ """Reference CCP runtime: validation, confirmation, execution, audit.
2
+
3
+ Implements the §10.3 validation sequence and the §26.3 side-effect state
4
+ machine. Confirmation is two-phase: :meth:`Runtime.submit` returns
5
+ ``confirmation_required`` with a ``pending_id``, and the trusted client
6
+ later calls :meth:`Runtime.confirm`.
7
+ """
8
+
9
+ import uuid
10
+
11
+ from . import canonical, crypto, schema
12
+ from .audit import AuditLog, utcnow
13
+ from .decision import decide
14
+ from .errors import AUTHORIZED, CONFIRMATION_REQUIRED, CCPError
15
+ from .hosts import HostRegistry
16
+ from .model import Decision, Signal
17
+ from .stores import (
18
+ GrantStore,
19
+ LimitTracker,
20
+ ManifestStore,
21
+ Pending,
22
+ PendingStore,
23
+ ReplayStore,
24
+ SchemaRegistry,
25
+ )
26
+
27
+
28
+ class Runtime:
29
+ def __init__(self, *, grants=None, manifests=None, replay=None, limits=None,
30
+ pending=None, schemas=None, hosts=None, audit=None, clock=None,
31
+ supported_ccp="0.4-draft"):
32
+ self.grants = grants if grants is not None else GrantStore()
33
+ self.manifests = manifests if manifests is not None else ManifestStore()
34
+ self.schemas = schemas if schemas is not None else SchemaRegistry()
35
+ self.hosts = hosts if hosts is not None else HostRegistry()
36
+ self.audit = audit if audit is not None else AuditLog()
37
+ self.replay = replay if replay is not None else ReplayStore()
38
+ self.limits = limits if limits is not None else LimitTracker()
39
+ self.pending_store = pending if pending is not None else PendingStore()
40
+ self.clock = clock or utcnow
41
+ self.supported_ccp = supported_ccp
42
+
43
+ # -- public API ------------------------------------------------------
44
+
45
+ def submit(self, document, verifying_key=None):
46
+ """Validate and act on one signed signal."""
47
+ now = self.clock()
48
+ try:
49
+ signal = Signal.parse(document)
50
+ except (canonical.CanonicalError, CCPError) as exc:
51
+ return self._reject("invalid_request", None, detail=str(exc))
52
+ request_id = signal.request_id
53
+
54
+ # authenticate the sender (transport provides the bound key)
55
+ if verifying_key is None:
56
+ return self._reject("unauthenticated", request_id,
57
+ detail="no authenticated signer")
58
+ try:
59
+ if not verifying_key.verify(signal._signing_input,
60
+ signal.data["signature"]):
61
+ return self._reject("unauthenticated", request_id,
62
+ detail="signature mismatch")
63
+ except (crypto.CryptoError, KeyError) as exc:
64
+ return self._reject("unauthenticated", request_id, detail=str(exc))
65
+
66
+ signer = verifying_key.key_id
67
+
68
+ # resolve the grant and its approved manifest
69
+ grant = self.grants.find(signer, signal.data["grant_digest"])
70
+ intent_spec = None
71
+ params_schema = None
72
+ if grant is not None:
73
+ manifest = self.manifests.get(grant.manifest_digest)
74
+ if manifest is not None:
75
+ intent_spec = manifest.intent_spec(signal.data["intent"])
76
+ if intent_spec is not None:
77
+ params_schema = self.schemas.get(
78
+ intent_spec.get("params_schema"))
79
+ counters = self.limits.counters(grant.id) if grant is not None else {}
80
+
81
+ decision = decide(
82
+ grant, signal.data, signer, now,
83
+ self.replay.snapshot(), counters,
84
+ intent_spec=intent_spec, params_schema=params_schema,
85
+ supported_ccp=self.supported_ccp,
86
+ )
87
+ if decision not in (AUTHORIZED, CONFIRMATION_REQUIRED):
88
+ return self._reject(decision, request_id, grant)
89
+
90
+ # accepted: apply replay and limit guards atomically
91
+ if not self.replay.mark_if_new(request_id):
92
+ return self._reject("replay_detected", request_id, grant)
93
+ if not self.limits.try_reserve(grant.id, grant.limits):
94
+ return self._reject("limit_exceeded", request_id, grant)
95
+
96
+ required = intent_spec.get("required_capabilities", [])
97
+ unavailable = [c for c in required if not self.hosts.has_host(c)]
98
+ if unavailable or not self.hosts.has_intent(signal.data["intent"]):
99
+ self.limits.release(grant.id, grant.limits)
100
+ return self._reject("capability_unavailable", request_id, grant,
101
+ detail="unavailable: %s"
102
+ % (unavailable or signal.data["intent"]))
103
+
104
+ if decision == CONFIRMATION_REQUIRED:
105
+ pending_id = uuid.uuid4().hex
106
+ self.pending_store.put(
107
+ Pending(pending_id, signal, grant, intent_spec))
108
+ self._audit("awaiting_confirmation", signal, grant)
109
+ return Decision(CONFIRMATION_REQUIRED, request_id=request_id,
110
+ pending_id=pending_id,
111
+ state="awaiting_confirmation")
112
+
113
+ return self._execute(signal, grant, intent_spec)
114
+
115
+ def confirm(self, pending_id, approved):
116
+ """Resolve a pending confirmation."""
117
+ pending = self.pending_store.take(pending_id)
118
+ if pending is None:
119
+ return self._reject("invalid_request", None,
120
+ detail="unknown pending request")
121
+
122
+ if not approved:
123
+ self.limits.release(pending.grant.id, pending.grant.limits)
124
+ self._audit("cancelled", pending.signal, pending.grant)
125
+ return Decision("cancelled", request_id=pending.signal.request_id,
126
+ state="cancelled")
127
+
128
+ # The grant may have changed while the prompt was open (§9.4.3).
129
+ state_error = self._grant_state_error(pending.grant, self.clock())
130
+ if state_error:
131
+ self.limits.release(pending.grant.id, pending.grant.limits)
132
+ return self._reject(state_error, pending.signal.request_id,
133
+ pending.grant)
134
+
135
+ return self._execute(pending.signal, pending.grant, pending.intent_spec)
136
+
137
+ def pending(self, pending_id):
138
+ return self.pending_store.get(pending_id)
139
+
140
+ # -- internals -------------------------------------------------------
141
+
142
+ def _execute(self, signal, grant, intent_spec):
143
+ self._audit("executing", signal, grant)
144
+ executor = self.hosts.intent(signal.data["intent"])
145
+ access = self.hosts.access(grant.capabilities)
146
+ try:
147
+ result = executor(signal.data["params"], access)
148
+ except Exception as exc: # noqa: BLE001 - surfaced as a typed outcome
149
+ self.limits.release(grant.id, grant.limits)
150
+ self._audit("failed", signal, grant, detail=str(exc))
151
+ return Decision("execution_failed", request_id=signal.request_id,
152
+ error="execution_failed", detail=str(exc),
153
+ state="failed")
154
+
155
+ result_schema = self.schemas.get(intent_spec.get("result_schema"))
156
+ if result_schema is not None:
157
+ problems = schema.validate(result, result_schema)
158
+ if problems:
159
+ self.limits.release(grant.id, grant.limits)
160
+ self._audit("failed", signal, grant, detail="invalid result")
161
+ return Decision("execution_failed", request_id=signal.request_id,
162
+ error="execution_failed",
163
+ detail="invalid result: " + "; ".join(problems),
164
+ state="failed")
165
+
166
+ self.limits.commit(grant.id, grant.limits)
167
+ self._audit("completed", signal, grant)
168
+ return Decision(AUTHORIZED, request_id=signal.request_id,
169
+ result=result, state="completed")
170
+
171
+ @staticmethod
172
+ def _grant_state_error(grant, now):
173
+ if grant.state == "revoked":
174
+ return "grant_revoked"
175
+ if grant.state == "paused":
176
+ return "grant_paused"
177
+ if grant.state == "suspended":
178
+ return "grant_suspended"
179
+ if grant.state == "expired" or now >= grant.expires_at:
180
+ return "grant_expired"
181
+ return None
182
+
183
+ def _reject(self, code, request_id, grant=None, detail=None):
184
+ self._audit("rejected", None, grant, request_id=request_id,
185
+ code=code, detail=detail)
186
+ return Decision(code, request_id=request_id, error=code, detail=detail)
187
+
188
+ def _audit(self, event, signal=None, grant=None, request_id=None,
189
+ code=None, detail=None):
190
+ record = {"event": event, "grant_id": grant.id if grant else None}
191
+ if signal is not None:
192
+ record["request_id"] = signal.request_id
193
+ record["intent"] = signal.data.get("intent")
194
+ if request_id is not None:
195
+ record["request_id"] = request_id
196
+ if code is not None:
197
+ record["code"] = code
198
+ if detail is not None:
199
+ record["detail"] = detail
200
+ self.audit.append(record)
ccp/schema.py ADDED
@@ -0,0 +1,134 @@
1
+ """A minimal JSON Schema subset validator per CCP §25.3.
2
+
3
+ Supports the frozen subset used by the profile: ``type``, ``properties``,
4
+ ``required``, ``additionalProperties``, ``items``, ``enum``, ``const``,
5
+ numeric bounds and ``multipleOf``, string length, array length and
6
+ uniqueness, and ``pattern``. Unknown keywords are ignored.
7
+
8
+ This is a reference validator, not a full JSON Schema implementation.
9
+ """
10
+
11
+ import re
12
+
13
+
14
+ class SchemaError(ValueError):
15
+ pass
16
+
17
+
18
+ def validate(value, schema):
19
+ """Return a list of human-readable violations (empty means valid)."""
20
+ errors = []
21
+ _validate(value, schema, "$", errors)
22
+ return errors
23
+
24
+
25
+ def is_valid(value, schema):
26
+ return not validate(value, schema)
27
+
28
+
29
+ def _type_ok(value, name):
30
+ if name == "object":
31
+ return isinstance(value, dict)
32
+ if name == "array":
33
+ return isinstance(value, list)
34
+ if name == "string":
35
+ return isinstance(value, str)
36
+ if name == "integer":
37
+ return isinstance(value, int) and not isinstance(value, bool)
38
+ if name == "number":
39
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
40
+ if name == "boolean":
41
+ return isinstance(value, bool)
42
+ if name == "null":
43
+ return value is None
44
+ raise SchemaError("unsupported type: %r" % (name,))
45
+
46
+
47
+ def _validate(value, schema, path, errors):
48
+ if schema is False:
49
+ errors.append("%s: not allowed" % path)
50
+ return
51
+ if schema is True or schema is None:
52
+ return
53
+
54
+ expected = schema.get("type")
55
+ if expected is not None:
56
+ names = expected if isinstance(expected, list) else [expected]
57
+ if not any(_type_ok(value, n) for n in names):
58
+ errors.append("%s: expected %s" % (path, "/".join(names)))
59
+ return
60
+
61
+ if "const" in schema and value != schema["const"]:
62
+ errors.append("%s: must equal %r" % (path, schema["const"]))
63
+ if "enum" in schema and value not in schema["enum"]:
64
+ errors.append("%s: must be one of %r" % (path, schema["enum"]))
65
+
66
+ if isinstance(value, bool):
67
+ return
68
+
69
+ if isinstance(value, (int, float)):
70
+ _validate_number(value, schema, path, errors)
71
+ if isinstance(value, str):
72
+ _validate_string(value, schema, path, errors)
73
+ if isinstance(value, list):
74
+ _validate_array(value, schema, path, errors)
75
+ if isinstance(value, dict):
76
+ _validate_object(value, schema, path, errors)
77
+
78
+
79
+ def _validate_number(value, schema, path, errors):
80
+ if "minimum" in schema and value < schema["minimum"]:
81
+ errors.append("%s: below minimum" % path)
82
+ if "maximum" in schema and value > schema["maximum"]:
83
+ errors.append("%s: above maximum" % path)
84
+ if "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"]:
85
+ errors.append("%s: not above exclusiveMinimum" % path)
86
+ if "exclusiveMaximum" in schema and value >= schema["exclusiveMaximum"]:
87
+ errors.append("%s: not below exclusiveMaximum" % path)
88
+ if "multipleOf" in schema:
89
+ step = schema["multipleOf"]
90
+ if step:
91
+ if (isinstance(value, int) and not isinstance(value, bool)
92
+ and isinstance(step, int)):
93
+ if value % step != 0:
94
+ errors.append("%s: not a multiple of %r" % (path, step))
95
+ elif abs(value / step - round(value / step)) > 1e-9:
96
+ errors.append("%s: not a multiple of %r" % (path, step))
97
+
98
+
99
+ def _validate_string(value, schema, path, errors):
100
+ if "minLength" in schema and len(value) < schema["minLength"]:
101
+ errors.append("%s: shorter than minLength" % path)
102
+ if "maxLength" in schema and len(value) > schema["maxLength"]:
103
+ errors.append("%s: longer than maxLength" % path)
104
+ if "pattern" in schema and re.search(schema["pattern"], value) is None:
105
+ errors.append("%s: does not match pattern" % path)
106
+
107
+
108
+ def _validate_array(value, schema, path, errors):
109
+ if "minItems" in schema and len(value) < schema["minItems"]:
110
+ errors.append("%s: fewer than minItems" % path)
111
+ if "maxItems" in schema and len(value) > schema["maxItems"]:
112
+ errors.append("%s: more than maxItems" % path)
113
+ if schema.get("uniqueItems"):
114
+ if len({repr(v) for v in value}) != len(value):
115
+ errors.append("%s: items are not unique" % path)
116
+ items = schema.get("items")
117
+ if items is not None:
118
+ for index, item in enumerate(value):
119
+ _validate(item, items, "%s[%d]" % (path, index), errors)
120
+
121
+
122
+ def _validate_object(value, schema, path, errors):
123
+ properties = schema.get("properties", {})
124
+ for name in schema.get("required", []):
125
+ if name not in value:
126
+ errors.append("%s.%s: required" % (path, name))
127
+ additional = schema.get("additionalProperties", True)
128
+ for name, item in value.items():
129
+ if name in properties:
130
+ _validate(item, properties[name], "%s.%s" % (path, name), errors)
131
+ elif additional is False:
132
+ errors.append("%s.%s: additional property not allowed" % (path, name))
133
+ elif isinstance(additional, dict):
134
+ _validate(item, additional, "%s.%s" % (path, name), errors)
ccp/signing.py ADDED
@@ -0,0 +1,14 @@
1
+ """Helpers for signing CCP documents."""
2
+
3
+ from . import canonical
4
+
5
+
6
+ def sign_document(value, signing_key):
7
+ """Return the canonical wire form of ``value`` signed by ``signing_key``.
8
+
9
+ The value is a mapping; any existing ``signature`` key is replaced.
10
+ """
11
+ value = dict(value)
12
+ value.pop("signature", None)
13
+ value["signature"] = signing_key.sign(canonical.signing_bytes_of(value))
14
+ return canonical.serialize(value)
ccp/state_machine.py ADDED
@@ -0,0 +1,33 @@
1
+ """The side-effect state machine (§26.3)."""
2
+
3
+ #: (from, event) -> to. ``None`` is the start pseudo-state.
4
+ TRANSITIONS = {
5
+ (None, "arrive"): "received",
6
+ ("received", "validate_ok"): "authorized",
7
+ ("authorized", "confirmation_required"): "awaiting_confirmation",
8
+ ("authorized", "no_confirmation"): "executing",
9
+ ("awaiting_confirmation", "approved"): "executing",
10
+ ("awaiting_confirmation", "denied"): "cancelled",
11
+ ("executing", "succeeded"): "completed",
12
+ ("executing", "failed"): "failed",
13
+ ("executing", "cancelled"): "cancelled",
14
+ ("executing", "outcome_lost"): "outcome_unknown",
15
+ }
16
+
17
+ TERMINAL = frozenset({"completed", "failed", "cancelled", "outcome_unknown"})
18
+
19
+
20
+ def next_state(state, event):
21
+ """Return the state after ``event``; raise ValueError if undefined."""
22
+ if state == "start":
23
+ state = None
24
+ try:
25
+ return TRANSITIONS[(state, event)]
26
+ except KeyError:
27
+ raise ValueError("no transition from %r on %r" % (state, event))
28
+
29
+
30
+ def reachable_states():
31
+ states = set(TRANSITIONS.values())
32
+ states.update(state for (state, _event) in TRANSITIONS if state is not None)
33
+ return states