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.
@@ -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",))
@@ -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