decisioncontrol 0.1.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.
- decisioncontrol/__init__.py +15 -0
- decisioncontrol/__main__.py +3 -0
- decisioncontrol/_outputs.py +28 -0
- decisioncontrol/cli.py +116 -0
- decisioncontrol/demo.py +70 -0
- decisioncontrol/evaluation.py +281 -0
- decisioncontrol/evidence.py +147 -0
- decisioncontrol/models.py +171 -0
- decisioncontrol/policy.py +32 -0
- decisioncontrol/store.py +317 -0
- decisioncontrol-0.1.0.dist-info/METADATA +333 -0
- decisioncontrol-0.1.0.dist-info/RECORD +16 -0
- decisioncontrol-0.1.0.dist-info/WHEEL +5 -0
- decisioncontrol-0.1.0.dist-info/entry_points.txt +2 -0
- decisioncontrol-0.1.0.dist-info/licenses/LICENSE +21 -0
- decisioncontrol-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Versioned, provider-neutral inputs for a bounded ticket action."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DecisionError(ValueError):
|
|
10
|
+
"""Invalid input or evidence; no action is authorized."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateError(DecisionError):
|
|
14
|
+
"""The requested lifecycle transition is not permitted."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Verdict(str, Enum):
|
|
18
|
+
ALLOW = "ALLOW"
|
|
19
|
+
DENY = "DENY"
|
|
20
|
+
REVIEW = "REVIEW"
|
|
21
|
+
ABSTAIN = "ABSTAIN"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Status(str, Enum):
|
|
25
|
+
READY = "READY"
|
|
26
|
+
PENDING_REVIEW = "PENDING_REVIEW"
|
|
27
|
+
DENIED = "DENIED"
|
|
28
|
+
ABSTAINED = "ABSTAINED"
|
|
29
|
+
REJECTED = "REJECTED"
|
|
30
|
+
CANCELLED = "CANCELLED"
|
|
31
|
+
EXECUTED = "EXECUTED"
|
|
32
|
+
STALE = "STALE"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fields(value, required, optional=()):
|
|
36
|
+
if not isinstance(value, Mapping):
|
|
37
|
+
raise DecisionError("expected a JSON object")
|
|
38
|
+
missing = set(required) - value.keys()
|
|
39
|
+
extra = value.keys() - set(required) - set(optional)
|
|
40
|
+
if missing or extra:
|
|
41
|
+
raise DecisionError(f"invalid fields: missing {sorted(missing)}, unexpected {sorted(extra, key=str)}")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _text(value, name):
|
|
46
|
+
if not isinstance(value, str) or not value or value != value.strip():
|
|
47
|
+
raise DecisionError(f"{name} must be a nonempty string without surrounding whitespace")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _probability(value, name):
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
53
|
+
raise DecisionError(f"{name} must be a finite probability in [0, 1]")
|
|
54
|
+
if not 0 <= value <= 1 or not math.isfinite(value):
|
|
55
|
+
raise DecisionError(f"{name} must be a finite probability in [0, 1]")
|
|
56
|
+
return float(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _names(value, name, allow_empty=False):
|
|
60
|
+
if not isinstance(value, (list, tuple)):
|
|
61
|
+
raise DecisionError(f"{name} must be a list or tuple of names")
|
|
62
|
+
names = tuple(_text(item, name) for item in value)
|
|
63
|
+
if (not names and not allow_empty) or len(set(names)) != len(names):
|
|
64
|
+
raise DecisionError(f"{name} must contain {'unique' if allow_empty else 'one or more unique'} names")
|
|
65
|
+
return names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Recommendation:
|
|
70
|
+
action: str
|
|
71
|
+
ticket_id: str
|
|
72
|
+
destination: str
|
|
73
|
+
probability: float | None
|
|
74
|
+
model: str
|
|
75
|
+
question_version: str
|
|
76
|
+
refused: bool = False
|
|
77
|
+
|
|
78
|
+
def __post_init__(self):
|
|
79
|
+
for name in ("action", "ticket_id", "destination", "model", "question_version"):
|
|
80
|
+
_text(getattr(self, name), name)
|
|
81
|
+
if self.probability is not None:
|
|
82
|
+
object.__setattr__(self, "probability", _probability(self.probability, "probability"))
|
|
83
|
+
if not isinstance(self.refused, bool):
|
|
84
|
+
raise DecisionError("refused must be a boolean")
|
|
85
|
+
|
|
86
|
+
def to_dict(self):
|
|
87
|
+
return {"action": self.action, "ticket_id": self.ticket_id,
|
|
88
|
+
"destination": self.destination, "probability": self.probability,
|
|
89
|
+
"model": self.model, "question_version": self.question_version,
|
|
90
|
+
"refused": self.refused}
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, value):
|
|
94
|
+
raw = _fields(value, {"action", "ticket_id", "destination", "model", "question_version"},
|
|
95
|
+
{"probability", "refused"})
|
|
96
|
+
return cls(action=raw["action"], ticket_id=raw["ticket_id"], destination=raw["destination"],
|
|
97
|
+
probability=raw.get("probability"), model=raw["model"],
|
|
98
|
+
question_version=raw["question_version"], refused=raw.get("refused", False))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Ticket:
|
|
103
|
+
id: str
|
|
104
|
+
department: str
|
|
105
|
+
revision: int = 0
|
|
106
|
+
|
|
107
|
+
def __post_init__(self):
|
|
108
|
+
_text(self.id, "ticket id")
|
|
109
|
+
_text(self.department, "department")
|
|
110
|
+
if type(self.revision) is not int or self.revision < 0:
|
|
111
|
+
raise DecisionError("revision must be a nonnegative integer")
|
|
112
|
+
|
|
113
|
+
def to_dict(self):
|
|
114
|
+
return {"id": self.id, "department": self.department, "revision": self.revision}
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, value):
|
|
118
|
+
raw = _fields(value, {"id", "department", "revision"})
|
|
119
|
+
return cls(**raw)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class RoutingPolicy:
|
|
124
|
+
version: str
|
|
125
|
+
allowed_destinations: tuple[str, ...]
|
|
126
|
+
review_destinations: tuple[str, ...]
|
|
127
|
+
reviewers: tuple[str, ...]
|
|
128
|
+
min_probability: float = 0.8
|
|
129
|
+
|
|
130
|
+
def __post_init__(self):
|
|
131
|
+
_text(self.version, "policy version")
|
|
132
|
+
object.__setattr__(self, "allowed_destinations", _names(self.allowed_destinations, "allowed_destinations"))
|
|
133
|
+
object.__setattr__(self, "review_destinations", _names(self.review_destinations, "review_destinations", True))
|
|
134
|
+
object.__setattr__(self, "reviewers", _names(self.reviewers, "reviewers"))
|
|
135
|
+
object.__setattr__(self, "min_probability", _probability(self.min_probability, "min_probability"))
|
|
136
|
+
if not set(self.review_destinations) <= set(self.allowed_destinations):
|
|
137
|
+
raise DecisionError("review_destinations must be a subset of allowed_destinations")
|
|
138
|
+
|
|
139
|
+
def to_dict(self):
|
|
140
|
+
return {"version": self.version, "allowed_destinations": list(self.allowed_destinations),
|
|
141
|
+
"review_destinations": list(self.review_destinations), "reviewers": list(self.reviewers),
|
|
142
|
+
"min_probability": self.min_probability}
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_dict(cls, value):
|
|
146
|
+
raw = _fields(value, {"version", "allowed_destinations", "review_destinations", "reviewers"},
|
|
147
|
+
{"min_probability"})
|
|
148
|
+
return cls(**raw)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Evaluation:
|
|
153
|
+
verdict: Verdict
|
|
154
|
+
reasons: tuple[str, ...]
|
|
155
|
+
|
|
156
|
+
def __post_init__(self):
|
|
157
|
+
if not isinstance(self.verdict, Verdict):
|
|
158
|
+
raise DecisionError("verdict must be a Verdict")
|
|
159
|
+
object.__setattr__(self, "reasons", _names(self.reasons, "reasons"))
|
|
160
|
+
|
|
161
|
+
def to_dict(self):
|
|
162
|
+
return {"verdict": self.verdict.value, "reasons": list(self.reasons)}
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_dict(cls, value):
|
|
166
|
+
raw = _fields(value, {"verdict", "reasons"})
|
|
167
|
+
try:
|
|
168
|
+
verdict = Verdict(raw["verdict"])
|
|
169
|
+
except (ValueError, TypeError) as error:
|
|
170
|
+
raise DecisionError("unknown verdict") from error
|
|
171
|
+
return cls(verdict, raw["reasons"])
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Deterministic authorization; model probabilities never grant permissions."""
|
|
2
|
+
|
|
3
|
+
from .models import Evaluation, Recommendation, RoutingPolicy, Ticket, Verdict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
INITIAL_STATUS = {
|
|
7
|
+
Verdict.ALLOW: "READY",
|
|
8
|
+
Verdict.REVIEW: "PENDING_REVIEW",
|
|
9
|
+
Verdict.DENY: "DENIED",
|
|
10
|
+
Verdict.ABSTAIN: "ABSTAINED",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def evaluate(recommendation: Recommendation, ticket: Ticket, policy: RoutingPolicy) -> Evaluation:
|
|
15
|
+
"""Evaluate a proposal against trusted ticket state and a trusted policy snapshot."""
|
|
16
|
+
if recommendation.ticket_id != ticket.id:
|
|
17
|
+
return Evaluation(Verdict.DENY, ("ticket_mismatch",))
|
|
18
|
+
if recommendation.action != "reassign_ticket":
|
|
19
|
+
return Evaluation(Verdict.DENY, ("action_not_permitted",))
|
|
20
|
+
if recommendation.destination not in policy.allowed_destinations:
|
|
21
|
+
return Evaluation(Verdict.DENY, ("destination_not_permitted",))
|
|
22
|
+
if recommendation.destination == ticket.department:
|
|
23
|
+
return Evaluation(Verdict.DENY, ("already_assigned",))
|
|
24
|
+
if recommendation.refused:
|
|
25
|
+
return Evaluation(Verdict.ABSTAIN, ("model_refused",))
|
|
26
|
+
if recommendation.probability is None:
|
|
27
|
+
return Evaluation(Verdict.ABSTAIN, ("probability_unknown",))
|
|
28
|
+
if recommendation.probability < policy.min_probability:
|
|
29
|
+
return Evaluation(Verdict.ABSTAIN, ("below_probability_threshold",))
|
|
30
|
+
if recommendation.destination in policy.review_destinations:
|
|
31
|
+
return Evaluation(Verdict.REVIEW, ("destination_requires_review",))
|
|
32
|
+
return Evaluation(Verdict.ALLOW, ("policy_permits_reassignment",))
|
decisioncontrol/store.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Durable decisions and atomic reassignment of a local SQLite ticket queue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import json
|
|
8
|
+
import hashlib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sqlite3
|
|
11
|
+
from threading import RLock
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .evidence import _canonical, _validate_record
|
|
17
|
+
from .models import (
|
|
18
|
+
DecisionError,
|
|
19
|
+
Recommendation,
|
|
20
|
+
RoutingPolicy,
|
|
21
|
+
StateError,
|
|
22
|
+
Status,
|
|
23
|
+
Ticket,
|
|
24
|
+
_text,
|
|
25
|
+
)
|
|
26
|
+
from .policy import INITIAL_STATUS, evaluate
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _now() -> str:
|
|
30
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
STORE_SCHEMA_VERSION = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PendingDecisions(list):
|
|
37
|
+
"""Valid pending records, with skipped row diagnostics in ``skipped``."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.skipped: list[dict[str, str]] = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TicketStore:
|
|
45
|
+
"""Store local tickets and frozen decision records.
|
|
46
|
+
|
|
47
|
+
Each mutation holds a SQLite write transaction, so independent processes or
|
|
48
|
+
connections cannot execute the same decision twice. A lock protects use of
|
|
49
|
+
one instance by multiple threads. Reviewer labels come from the trusted
|
|
50
|
+
caller; membership in the frozen policy is authorization, not authentication.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, db_path: str | Path):
|
|
54
|
+
self._lock = RLock()
|
|
55
|
+
self._closed = False
|
|
56
|
+
self._connection = sqlite3.connect(
|
|
57
|
+
db_path, timeout=30.0, isolation_level=None, check_same_thread=False,
|
|
58
|
+
)
|
|
59
|
+
self._connection.row_factory = sqlite3.Row
|
|
60
|
+
try:
|
|
61
|
+
with self._transaction() as connection:
|
|
62
|
+
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
63
|
+
if version > STORE_SCHEMA_VERSION:
|
|
64
|
+
raise DecisionError(f"Unsupported store schema version {version}; supported: {STORE_SCHEMA_VERSION}")
|
|
65
|
+
connection.execute(
|
|
66
|
+
"CREATE TABLE IF NOT EXISTS tickets ("
|
|
67
|
+
"id TEXT PRIMARY KEY NOT NULL, department TEXT NOT NULL, "
|
|
68
|
+
"revision INTEGER NOT NULL CHECK (revision >= 0))"
|
|
69
|
+
)
|
|
70
|
+
connection.execute(
|
|
71
|
+
"CREATE TABLE IF NOT EXISTS decisions ("
|
|
72
|
+
"id TEXT PRIMARY KEY NOT NULL, created_at TEXT NOT NULL, "
|
|
73
|
+
"status TEXT NOT NULL, record TEXT NOT NULL)"
|
|
74
|
+
)
|
|
75
|
+
connection.execute(
|
|
76
|
+
"CREATE INDEX IF NOT EXISTS decisions_pending "
|
|
77
|
+
"ON decisions (status, created_at, id)"
|
|
78
|
+
)
|
|
79
|
+
connection.execute(f"PRAGMA user_version = {STORE_SCHEMA_VERSION}")
|
|
80
|
+
except BaseException:
|
|
81
|
+
self.close()
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> TicketStore:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._ensure_open()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
with self._lock:
|
|
94
|
+
if not self._closed:
|
|
95
|
+
self._connection.close()
|
|
96
|
+
self._closed = True
|
|
97
|
+
|
|
98
|
+
def _ensure_open(self) -> None:
|
|
99
|
+
if self._closed:
|
|
100
|
+
raise StateError("TicketStore is closed")
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def _transaction(self) -> Iterator[sqlite3.Connection]:
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._ensure_open()
|
|
106
|
+
self._connection.execute("BEGIN IMMEDIATE")
|
|
107
|
+
try:
|
|
108
|
+
yield self._connection
|
|
109
|
+
self._connection.commit()
|
|
110
|
+
except BaseException:
|
|
111
|
+
if self._connection.in_transaction:
|
|
112
|
+
self._connection.rollback()
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
def create_ticket(self, ticket_id: str, department: str) -> Ticket:
|
|
116
|
+
ticket = Ticket.from_dict({"id": ticket_id, "department": department, "revision": 0})
|
|
117
|
+
with self._transaction() as connection:
|
|
118
|
+
if connection.execute("SELECT 1 FROM tickets WHERE id = ?", (ticket.id,)).fetchone():
|
|
119
|
+
raise DecisionError(f"Ticket already exists: {ticket.id}")
|
|
120
|
+
connection.execute(
|
|
121
|
+
"INSERT INTO tickets (id, department, revision) VALUES (?, ?, ?)",
|
|
122
|
+
(ticket.id, ticket.department, ticket.revision),
|
|
123
|
+
)
|
|
124
|
+
return ticket
|
|
125
|
+
|
|
126
|
+
def _get_ticket(self, ticket_id: str) -> Ticket:
|
|
127
|
+
if not isinstance(ticket_id, str) or not ticket_id or ticket_id != ticket_id.strip():
|
|
128
|
+
raise DecisionError("Ticket ID must be a nonempty, stripped string")
|
|
129
|
+
row = self._connection.execute(
|
|
130
|
+
"SELECT id, department, revision FROM tickets WHERE id = ?", (ticket_id,),
|
|
131
|
+
).fetchone()
|
|
132
|
+
if row is None:
|
|
133
|
+
raise DecisionError(f"Unknown ticket: {ticket_id}")
|
|
134
|
+
return Ticket.from_dict(dict(row))
|
|
135
|
+
|
|
136
|
+
def get_ticket(self, ticket_id: str) -> Ticket:
|
|
137
|
+
with self._lock:
|
|
138
|
+
self._ensure_open()
|
|
139
|
+
return self._get_ticket(ticket_id)
|
|
140
|
+
|
|
141
|
+
def submit(self, recommendation: Recommendation, policy: RoutingPolicy, proposed_by: str) -> dict:
|
|
142
|
+
if not isinstance(recommendation, Recommendation) or not isinstance(policy, RoutingPolicy):
|
|
143
|
+
raise DecisionError("submit requires a Recommendation and a RoutingPolicy")
|
|
144
|
+
_text(proposed_by, "proposed_by")
|
|
145
|
+
recommendation = Recommendation.from_dict(recommendation.to_dict())
|
|
146
|
+
policy = RoutingPolicy.from_dict(policy.to_dict())
|
|
147
|
+
with self._transaction() as connection:
|
|
148
|
+
ticket = self._get_ticket(recommendation.ticket_id)
|
|
149
|
+
evaluation = evaluate(recommendation, ticket, policy)
|
|
150
|
+
created_at = _now()
|
|
151
|
+
record = {
|
|
152
|
+
"schema_version": 3,
|
|
153
|
+
"produced_by": {"name": "decisioncontrol", "version": __version__},
|
|
154
|
+
"proposed_by": proposed_by,
|
|
155
|
+
"policy_sha256": hashlib.sha256(_canonical(policy.to_dict())).hexdigest(),
|
|
156
|
+
"id": str(uuid4()),
|
|
157
|
+
"created_at": created_at,
|
|
158
|
+
"recommendation": recommendation.to_dict(),
|
|
159
|
+
"ticket": ticket.to_dict(),
|
|
160
|
+
"policy": policy.to_dict(),
|
|
161
|
+
"evaluation": evaluation.to_dict(),
|
|
162
|
+
"status": INITIAL_STATUS[evaluation.verdict],
|
|
163
|
+
"review": None,
|
|
164
|
+
"result": None,
|
|
165
|
+
"events": [{"event": "SUBMITTED", "at": created_at}],
|
|
166
|
+
}
|
|
167
|
+
connection.execute(
|
|
168
|
+
"INSERT INTO decisions (id, created_at, status, record) VALUES (?, ?, ?, ?)",
|
|
169
|
+
(record["id"], created_at, record["status"], self._serialize(record)),
|
|
170
|
+
)
|
|
171
|
+
return record
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _serialize(record: dict) -> str:
|
|
175
|
+
return json.dumps(record, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _decode(row: sqlite3.Row) -> dict:
|
|
179
|
+
try:
|
|
180
|
+
record = json.loads(row["record"])
|
|
181
|
+
except (json.JSONDecodeError, TypeError, UnicodeError) as exc:
|
|
182
|
+
raise DecisionError("Stored decision is not valid JSON") from exc
|
|
183
|
+
_validate_record(record)
|
|
184
|
+
if (
|
|
185
|
+
record["id"] != row["id"]
|
|
186
|
+
or record["created_at"] != row["created_at"]
|
|
187
|
+
or record["status"] != row["status"]
|
|
188
|
+
):
|
|
189
|
+
raise DecisionError("Stored decision disagrees with its database index")
|
|
190
|
+
return record
|
|
191
|
+
|
|
192
|
+
def _get(self, decision_id: str) -> dict:
|
|
193
|
+
if not isinstance(decision_id, str) or not decision_id or decision_id != decision_id.strip():
|
|
194
|
+
raise DecisionError("Decision ID must be a nonempty, stripped string")
|
|
195
|
+
row = self._connection.execute(
|
|
196
|
+
"SELECT id, created_at, status, record FROM decisions WHERE id = ?", (decision_id,),
|
|
197
|
+
).fetchone()
|
|
198
|
+
if row is None:
|
|
199
|
+
raise DecisionError(f"Unknown decision: {decision_id}")
|
|
200
|
+
return self._decode(row)
|
|
201
|
+
|
|
202
|
+
def get(self, decision_id: str) -> dict:
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._ensure_open()
|
|
205
|
+
return self._get(decision_id)
|
|
206
|
+
|
|
207
|
+
def pending(self) -> PendingDecisions:
|
|
208
|
+
with self._lock:
|
|
209
|
+
self._ensure_open()
|
|
210
|
+
rows = self._connection.execute(
|
|
211
|
+
"SELECT id, created_at, status, record FROM decisions "
|
|
212
|
+
"WHERE status = ? ORDER BY created_at, id",
|
|
213
|
+
(Status.PENDING_REVIEW.value,),
|
|
214
|
+
).fetchall()
|
|
215
|
+
pending = PendingDecisions()
|
|
216
|
+
for row in rows:
|
|
217
|
+
try:
|
|
218
|
+
pending.append(self._decode(row))
|
|
219
|
+
except DecisionError as error:
|
|
220
|
+
pending.skipped.append({"id": row["id"], "error": str(error)})
|
|
221
|
+
return pending
|
|
222
|
+
|
|
223
|
+
def _save(self, record: dict) -> None:
|
|
224
|
+
_validate_record(record)
|
|
225
|
+
self._connection.execute(
|
|
226
|
+
"UPDATE decisions SET status = ?, record = ? WHERE id = ?",
|
|
227
|
+
(record["status"], self._serialize(record), record["id"]),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _authorize_reviewer(record: dict, reviewer: str, note: str) -> None:
|
|
232
|
+
if not isinstance(reviewer, str) or not reviewer or reviewer != reviewer.strip():
|
|
233
|
+
raise DecisionError("Reviewer must be a nonempty, stripped identifier")
|
|
234
|
+
if not isinstance(note, str):
|
|
235
|
+
raise DecisionError("Review note must be a string")
|
|
236
|
+
if (record["schema_version"] == 3 and record["evaluation"]["verdict"] == "REVIEW"
|
|
237
|
+
and reviewer == record["proposed_by"]):
|
|
238
|
+
raise StateError("The proposer cannot review or cancel their own REVIEW decision")
|
|
239
|
+
# The frozen policy has already been validated by _get.
|
|
240
|
+
if reviewer not in record["policy"]["reviewers"]:
|
|
241
|
+
raise StateError(f"Reviewer is not authorized by this decision's policy: {reviewer}")
|
|
242
|
+
|
|
243
|
+
def _review(self, decision_id: str, reviewer: str, note: str, decision: str) -> dict:
|
|
244
|
+
with self._transaction():
|
|
245
|
+
record = self._get(decision_id)
|
|
246
|
+
self._authorize_reviewer(record, reviewer, note)
|
|
247
|
+
previous = record["review"]
|
|
248
|
+
if previous is not None and (
|
|
249
|
+
previous["decision"] == decision
|
|
250
|
+
and previous["reviewer"] == reviewer
|
|
251
|
+
and previous["note"] == note
|
|
252
|
+
and record["status"] != Status.CANCELLED.value
|
|
253
|
+
):
|
|
254
|
+
return record
|
|
255
|
+
if record["status"] != Status.PENDING_REVIEW.value:
|
|
256
|
+
raise StateError(f"Cannot record {decision.lower()} review in state {record['status']}")
|
|
257
|
+
at = _now()
|
|
258
|
+
record["review"] = {"decision": decision, "reviewer": reviewer, "note": note, "at": at}
|
|
259
|
+
record["status"] = Status.READY.value if decision == "APPROVED" else Status.REJECTED.value
|
|
260
|
+
record["events"].append({"event": decision, "at": at})
|
|
261
|
+
self._save(record)
|
|
262
|
+
return record
|
|
263
|
+
|
|
264
|
+
def approve(self, decision_id: str, reviewer: str, note: str = "") -> dict:
|
|
265
|
+
return self._review(decision_id, reviewer, note, "APPROVED")
|
|
266
|
+
|
|
267
|
+
def reject(self, decision_id: str, reviewer: str, note: str = "") -> dict:
|
|
268
|
+
return self._review(decision_id, reviewer, note, "REJECTED")
|
|
269
|
+
|
|
270
|
+
def cancel(self, decision_id: str, reviewer: str, note: str) -> dict:
|
|
271
|
+
"""Withdraw an unexecuted decision; retain any prior approval and its provenance."""
|
|
272
|
+
with self._transaction():
|
|
273
|
+
record = self._get(decision_id)
|
|
274
|
+
self._authorize_reviewer(record, reviewer, note)
|
|
275
|
+
if not note.strip():
|
|
276
|
+
raise DecisionError("Cancellation requires a nonempty reason")
|
|
277
|
+
if record["status"] == Status.CANCELLED.value:
|
|
278
|
+
cancellation = record["events"][-1]
|
|
279
|
+
if cancellation["reviewer"] == reviewer and cancellation["note"] == note:
|
|
280
|
+
return record
|
|
281
|
+
raise StateError("Cancellation is already recorded with a different actor or reason")
|
|
282
|
+
if record["status"] not in (Status.READY.value, Status.PENDING_REVIEW.value):
|
|
283
|
+
raise StateError(f"Cannot cancel decision in state {record['status']}")
|
|
284
|
+
if record["schema_version"] == 1:
|
|
285
|
+
record["schema_version"] = 2
|
|
286
|
+
record["status"] = Status.CANCELLED.value
|
|
287
|
+
record["events"].append({
|
|
288
|
+
"event": "CANCELLED", "at": _now(), "reviewer": reviewer, "note": note,
|
|
289
|
+
})
|
|
290
|
+
self._save(record)
|
|
291
|
+
return record
|
|
292
|
+
|
|
293
|
+
def execute(self, decision_id: str) -> dict:
|
|
294
|
+
with self._transaction() as connection:
|
|
295
|
+
record = self._get(decision_id)
|
|
296
|
+
if record["status"] == Status.EXECUTED.value:
|
|
297
|
+
return record
|
|
298
|
+
if record["status"] != Status.READY.value:
|
|
299
|
+
raise StateError(f"Cannot execute decision in state {record['status']}")
|
|
300
|
+
frozen_ticket = Ticket.from_dict(record["ticket"])
|
|
301
|
+
recommendation = Recommendation.from_dict(record["recommendation"])
|
|
302
|
+
current = self._get_ticket(frozen_ticket.id)
|
|
303
|
+
at = _now()
|
|
304
|
+
if current.revision != frozen_ticket.revision or current.department != frozen_ticket.department:
|
|
305
|
+
record["status"] = Status.STALE.value
|
|
306
|
+
record["events"].append({"event": "STALE", "at": at})
|
|
307
|
+
else:
|
|
308
|
+
result = Ticket(current.id, recommendation.destination, current.revision + 1)
|
|
309
|
+
connection.execute(
|
|
310
|
+
"UPDATE tickets SET department = ?, revision = ? WHERE id = ?",
|
|
311
|
+
(result.department, result.revision, result.id),
|
|
312
|
+
)
|
|
313
|
+
record["result"] = result.to_dict()
|
|
314
|
+
record["status"] = Status.EXECUTED.value
|
|
315
|
+
record["events"].append({"event": "EXECUTED", "at": at})
|
|
316
|
+
self._save(record)
|
|
317
|
+
return record
|