docketry 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.
docketry/pipeline.py ADDED
@@ -0,0 +1,157 @@
1
+ """The gate runner: enforcement lives here, not in prompts.
2
+
3
+ A message advances through the manifest's stages. Entering a stage runs every
4
+ gate bound to it. A failed gate resolves per its configured on_fail:
5
+
6
+ block -> the message stops; only a recorded override by the gate's declared
7
+ authority lets it continue.
8
+ bounce -> the message parks in the human review queue; a recorded approval
9
+ by the declared authority releases it.
10
+ warn -> the finding is recorded and the message proceeds.
11
+
12
+ advance() is the only code path that moves a message forward, and it re-checks
13
+ approvals every time — there is no advisory mode and no bypass flag.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Protocol
19
+
20
+ from . import store as st
21
+ from .envelope import Envelope
22
+
23
+ SEVERITY_FAIL = "fail"
24
+ SEVERITY_WARN = "warn"
25
+ SEVERITY_INFO = "info"
26
+
27
+ ON_FAIL = ("block", "bounce", "warn")
28
+
29
+
30
+ @dataclass
31
+ class Finding:
32
+ gate_id: str
33
+ severity: str
34
+ summary: str
35
+
36
+
37
+ class Gate(Protocol):
38
+ """A gate checks one thing about a message and reports findings.
39
+
40
+ `allowed_stages` declares where in the pipeline this gate is meant to run
41
+ (None = anywhere). Binding it elsewhere is a manifest error, so the
42
+ meant-for / not-meant-for scoping is enforced at load time, not documented
43
+ in prose.
44
+ """
45
+
46
+ id: str
47
+ allowed_stages: set[str] | None
48
+
49
+ def check(self, envelope: Envelope, options: dict) -> list[Finding]: ...
50
+
51
+
52
+ @dataclass
53
+ class GateBinding:
54
+ gate: Gate
55
+ binds_to: list[str]
56
+ on_fail: str = "bounce"
57
+ authority: str = "attorney"
58
+ options: dict = field(default_factory=dict)
59
+
60
+
61
+ class GateRefusal(Exception):
62
+ """Raised when advance() is asked to move a message a gate is holding."""
63
+
64
+
65
+ @dataclass
66
+ class Pipeline:
67
+ stages: list[str]
68
+ bindings: list[GateBinding]
69
+
70
+ def bindings_for(self, stage: str) -> list[GateBinding]:
71
+ return [b for b in self.bindings if stage in b.binds_to]
72
+
73
+ def next_stage(self, stage: str) -> str | None:
74
+ idx = self.stages.index(stage)
75
+ return self.stages[idx + 1] if idx + 1 < len(self.stages) else None
76
+
77
+
78
+ class Runner:
79
+ def __init__(self, pipeline: Pipeline, store: st.Store):
80
+ self.pipeline = pipeline
81
+ self.store = store
82
+
83
+ # -- internals -------------------------------------------------------
84
+ def _run_stage_gates(self, msg_id: int, stage: str, env: Envelope) -> str:
85
+ """Run every gate bound to `stage`; return the resulting status."""
86
+ status = st.OK
87
+ for binding in self.pipeline.bindings_for(stage):
88
+ cleared = binding.authority in self.store.approval_roles(
89
+ msg_id, stage, binding.gate.id
90
+ )
91
+ findings = binding.gate.check(env, binding.options)
92
+ failed = False
93
+ for f in findings:
94
+ self.store.add_finding(msg_id, stage, f.gate_id, f.severity, f.summary)
95
+ if f.severity == SEVERITY_FAIL:
96
+ failed = True
97
+ if not failed or cleared:
98
+ continue
99
+ if binding.on_fail == "block":
100
+ status = st.BLOCKED
101
+ elif binding.on_fail == "bounce" and status != st.BLOCKED:
102
+ status = st.PENDING_REVIEW
103
+ return status
104
+
105
+ def _envelope(self, msg_id: int) -> Envelope:
106
+ import json
107
+
108
+ row = self.store.get_message(msg_id)
109
+ if row is None:
110
+ raise KeyError(f"no message {msg_id}")
111
+ d = json.loads(row["envelope_json"])
112
+ d["attachments"] = [
113
+ {**a, "content": b""} for a in d.get("attachments", [])
114
+ ]
115
+ from .envelope import Attachment
116
+
117
+ d["attachments"] = [Attachment(**a) for a in d["attachments"]]
118
+ return Envelope(**d)
119
+
120
+ # -- public API ------------------------------------------------------
121
+ def enter(self, msg_id: int) -> str:
122
+ """Run the gates of the message's current stage (used at ingest)."""
123
+ row = self.store.get_message(msg_id)
124
+ status = self._run_stage_gates(msg_id, row["stage"], self._envelope(msg_id))
125
+ if status == st.OK and self.pipeline.next_stage(row["stage"]) is None:
126
+ status = st.DONE
127
+ self.store.set_state(msg_id, status=status)
128
+ return status
129
+
130
+ def advance(self, msg_id: int) -> str:
131
+ """Move one stage forward. The ONLY forward path; re-checks holds."""
132
+ row = self.store.get_message(msg_id)
133
+ if row is None:
134
+ raise KeyError(f"no message {msg_id}")
135
+ env = self._envelope(msg_id)
136
+
137
+ if row["status"] in (st.BLOCKED, st.PENDING_REVIEW):
138
+ # Holds only clear through recorded approvals; re-run the stage.
139
+ status = self._run_stage_gates(msg_id, row["stage"], env)
140
+ if status != st.OK:
141
+ self.store.set_state(msg_id, status=status)
142
+ raise GateRefusal(
143
+ f"message {msg_id} is {status} at stage '{row['stage']}';"
144
+ " approval by the declared authority is required"
145
+ )
146
+ self.store.set_state(msg_id, status=st.OK)
147
+
148
+ nxt = self.pipeline.next_stage(row["stage"])
149
+ if nxt is None:
150
+ self.store.set_state(msg_id, status=st.DONE)
151
+ return st.DONE
152
+
153
+ status = self._run_stage_gates(msg_id, nxt, env)
154
+ if status == st.OK and self.pipeline.next_stage(nxt) is None:
155
+ status = st.DONE
156
+ self.store.set_state(msg_id, stage=nxt, status=status)
157
+ return status
docketry/store.py ADDED
@@ -0,0 +1,340 @@
1
+ """Local SQLite store: the pipeline's single source of truth.
2
+
3
+ Everything lives on the firm's own disk. Ingest is idempotent (keyed on the
4
+ raw message hash), attachment bytes go to a content-addressed directory, and
5
+ every gate finding and human approval lands in an audit table.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from .envelope import Envelope
15
+
16
+ SCHEMA = """
17
+ CREATE TABLE IF NOT EXISTS messages(
18
+ id INTEGER PRIMARY KEY,
19
+ message_id TEXT NOT NULL,
20
+ raw_sha256 TEXT NOT NULL UNIQUE,
21
+ source TEXT NOT NULL,
22
+ fetched_at TEXT NOT NULL,
23
+ envelope_json TEXT NOT NULL,
24
+ stage TEXT NOT NULL,
25
+ status TEXT NOT NULL DEFAULT 'ok',
26
+ updated_at TEXT NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS attachments(
29
+ id INTEGER PRIMARY KEY,
30
+ message_id INTEGER NOT NULL REFERENCES messages(id),
31
+ filename TEXT NOT NULL,
32
+ content_type TEXT,
33
+ sha256 TEXT NOT NULL,
34
+ size INTEGER NOT NULL,
35
+ path TEXT NOT NULL
36
+ );
37
+ CREATE TABLE IF NOT EXISTS findings(
38
+ id INTEGER PRIMARY KEY,
39
+ message_id INTEGER NOT NULL REFERENCES messages(id),
40
+ stage TEXT NOT NULL,
41
+ gate_id TEXT NOT NULL,
42
+ severity TEXT NOT NULL,
43
+ summary TEXT NOT NULL,
44
+ created_at TEXT NOT NULL
45
+ );
46
+ CREATE TABLE IF NOT EXISTS approvals(
47
+ id INTEGER PRIMARY KEY,
48
+ message_id INTEGER NOT NULL REFERENCES messages(id),
49
+ stage TEXT NOT NULL,
50
+ gate_id TEXT NOT NULL,
51
+ approved_by TEXT NOT NULL,
52
+ role TEXT NOT NULL,
53
+ note TEXT,
54
+ created_at TEXT NOT NULL
55
+ );
56
+ CREATE TABLE IF NOT EXISTS notices(
57
+ id INTEGER PRIMARY KEY,
58
+ message_id INTEGER NOT NULL REFERENCES messages(id),
59
+ adapter TEXT NOT NULL,
60
+ notice_type TEXT NOT NULL,
61
+ fields_json TEXT NOT NULL,
62
+ missing_json TEXT NOT NULL,
63
+ created_at TEXT NOT NULL
64
+ );
65
+ CREATE TABLE IF NOT EXISTS classifications(
66
+ id INTEGER PRIMARY KEY,
67
+ attachment_id INTEGER NOT NULL REFERENCES attachments(id),
68
+ label TEXT NOT NULL,
69
+ tier TEXT NOT NULL,
70
+ applied INTEGER NOT NULL DEFAULT 0,
71
+ applied_by TEXT,
72
+ applied_role TEXT,
73
+ applied_at TEXT,
74
+ created_at TEXT NOT NULL
75
+ );
76
+ CREATE TABLE IF NOT EXISTS imap_state(
77
+ mailbox TEXT PRIMARY KEY,
78
+ uidvalidity INTEGER,
79
+ last_uid INTEGER NOT NULL DEFAULT 0
80
+ );
81
+ """
82
+
83
+ # Statuses a message row can hold.
84
+ OK = "ok"
85
+ PENDING_REVIEW = "pending_review"
86
+ BLOCKED = "blocked"
87
+ DONE = "done"
88
+
89
+
90
+ def utcnow() -> str:
91
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
92
+
93
+
94
+ class Store:
95
+ def __init__(self, root: str | Path):
96
+ self.root = Path(root)
97
+ self.root.mkdir(parents=True, exist_ok=True)
98
+ self.attachments_dir = self.root / "attachments"
99
+ self.attachments_dir.mkdir(exist_ok=True)
100
+ self.db = sqlite3.connect(self.root / "docketry.db")
101
+ self.db.row_factory = sqlite3.Row
102
+ self.db.executescript(SCHEMA)
103
+ cols = {r["name"] for r in self.db.execute("PRAGMA table_info(attachments)")}
104
+ if "doc_type" not in cols:
105
+ with self.db:
106
+ self.db.execute("ALTER TABLE attachments ADD COLUMN doc_type TEXT")
107
+
108
+ def close(self) -> None:
109
+ self.db.close()
110
+
111
+ # -- ingest ----------------------------------------------------------
112
+ def ingest(self, env: Envelope, *, first_stage: str) -> int | None:
113
+ """Persist an envelope; return row id, or None if already seen."""
114
+ seen = self.db.execute(
115
+ "SELECT id FROM messages WHERE raw_sha256=?", (env.raw_sha256,)
116
+ ).fetchone()
117
+ if seen:
118
+ return None
119
+ now = utcnow()
120
+ with self.db:
121
+ cur = self.db.execute(
122
+ "INSERT INTO messages(message_id, raw_sha256, source, fetched_at,"
123
+ " envelope_json, stage, status, updated_at) VALUES(?,?,?,?,?,?,?,?)",
124
+ (
125
+ env.message_id,
126
+ env.raw_sha256,
127
+ env.source,
128
+ env.fetched_at,
129
+ json.dumps(env.to_record()),
130
+ first_stage,
131
+ OK,
132
+ now,
133
+ ),
134
+ )
135
+ msg_id = cur.lastrowid
136
+ for a in env.attachments:
137
+ sub = self.attachments_dir / a.sha256[:2]
138
+ sub.mkdir(exist_ok=True)
139
+ path = sub / f"{a.sha256[:16]}_{a.filename}"
140
+ if not path.exists():
141
+ path.write_bytes(a.content)
142
+ self.db.execute(
143
+ "INSERT INTO attachments(message_id, filename, content_type,"
144
+ " sha256, size, path) VALUES(?,?,?,?,?,?)",
145
+ (msg_id, a.filename, a.content_type, a.sha256, a.size, str(path)),
146
+ )
147
+ return msg_id
148
+
149
+ # -- pipeline state --------------------------------------------------
150
+ def get_message(self, msg_id: int) -> sqlite3.Row | None:
151
+ return self.db.execute("SELECT * FROM messages WHERE id=?", (msg_id,)).fetchone()
152
+
153
+ def set_state(self, msg_id: int, *, stage: str | None = None, status: str | None = None) -> None:
154
+ row = self.get_message(msg_id)
155
+ if row is None:
156
+ raise KeyError(f"no message {msg_id}")
157
+ with self.db:
158
+ self.db.execute(
159
+ "UPDATE messages SET stage=?, status=?, updated_at=? WHERE id=?",
160
+ (stage or row["stage"], status or row["status"], utcnow(), msg_id),
161
+ )
162
+
163
+ def add_finding(self, msg_id: int, stage: str, gate_id: str, severity: str, summary: str) -> None:
164
+ with self.db:
165
+ self.db.execute(
166
+ "INSERT INTO findings(message_id, stage, gate_id, severity, summary,"
167
+ " created_at) VALUES(?,?,?,?,?,?)",
168
+ (msg_id, stage, gate_id, severity, summary, utcnow()),
169
+ )
170
+
171
+ def add_approval(
172
+ self, msg_id: int, stage: str, gate_id: str, *, approved_by: str, role: str, note: str = ""
173
+ ) -> None:
174
+ with self.db:
175
+ self.db.execute(
176
+ "INSERT INTO approvals(message_id, stage, gate_id, approved_by, role,"
177
+ " note, created_at) VALUES(?,?,?,?,?,?,?)",
178
+ (msg_id, stage, gate_id, approved_by, role, note, utcnow()),
179
+ )
180
+
181
+ def approval_roles(self, msg_id: int, stage: str, gate_id: str) -> set[str]:
182
+ rows = self.db.execute(
183
+ "SELECT role FROM approvals WHERE message_id=? AND stage=? AND gate_id=?",
184
+ (msg_id, stage, gate_id),
185
+ ).fetchall()
186
+ return {r["role"] for r in rows}
187
+
188
+ def findings_for(self, msg_id: int) -> list[sqlite3.Row]:
189
+ return self.db.execute(
190
+ "SELECT * FROM findings WHERE message_id=? ORDER BY id", (msg_id,)
191
+ ).fetchall()
192
+
193
+ def list_by_status(self, status: str) -> list[sqlite3.Row]:
194
+ return self.db.execute(
195
+ "SELECT * FROM messages WHERE status=? ORDER BY id", (status,)
196
+ ).fetchall()
197
+
198
+ def counts(self) -> dict[str, int]:
199
+ rows = self.db.execute(
200
+ "SELECT status, COUNT(*) n FROM messages GROUP BY status"
201
+ ).fetchall()
202
+ return {r["status"]: r["n"] for r in rows}
203
+
204
+ # -- notices ---------------------------------------------------------
205
+ def add_notice(self, msg_id: int, adapter: str, notice_type: str,
206
+ fields: dict, missing: list[str]) -> None:
207
+ with self.db:
208
+ self.db.execute(
209
+ "INSERT INTO notices(message_id, adapter, notice_type,"
210
+ " fields_json, missing_json, created_at) VALUES(?,?,?,?,?,?)",
211
+ (msg_id, adapter, notice_type, json.dumps(fields),
212
+ json.dumps(missing), utcnow()),
213
+ )
214
+
215
+ def list_notices(self, notice_type: str | None = None) -> list[sqlite3.Row]:
216
+ if notice_type:
217
+ return self.db.execute(
218
+ "SELECT * FROM notices WHERE notice_type=? ORDER BY id",
219
+ (notice_type,),
220
+ ).fetchall()
221
+ return self.db.execute("SELECT * FROM notices ORDER BY id").fetchall()
222
+
223
+ # -- classifications (stage-for-approval, fill-only) -----------------
224
+ def stage_classification(self, attachment_id: int, label: str, tier: str) -> int | None:
225
+ """Stage a proposed doc type; skip if an open proposal already exists."""
226
+ row = self.db.execute(
227
+ "SELECT id FROM classifications WHERE attachment_id=? AND applied=0",
228
+ (attachment_id,),
229
+ ).fetchone()
230
+ if row:
231
+ return None
232
+ with self.db:
233
+ cur = self.db.execute(
234
+ "INSERT INTO classifications(attachment_id, label, tier, created_at)"
235
+ " VALUES(?,?,?,?)",
236
+ (attachment_id, label, tier, utcnow()),
237
+ )
238
+ return cur.lastrowid
239
+
240
+ def open_classifications(self) -> list[sqlite3.Row]:
241
+ return self.db.execute(
242
+ "SELECT c.*, a.filename, a.doc_type FROM classifications c"
243
+ " JOIN attachments a ON a.id = c.attachment_id"
244
+ " WHERE c.applied=0 ORDER BY c.id"
245
+ ).fetchall()
246
+
247
+ def apply_classification(self, class_id: int, *, by: str, role: str) -> str:
248
+ """Fill-only: sets attachments.doc_type only when it is NULL."""
249
+ row = self.db.execute(
250
+ "SELECT c.*, a.doc_type FROM classifications c"
251
+ " JOIN attachments a ON a.id = c.attachment_id WHERE c.id=?",
252
+ (class_id,),
253
+ ).fetchone()
254
+ if row is None:
255
+ raise KeyError(f"no classification {class_id}")
256
+ if row["applied"]:
257
+ return "already-applied"
258
+ with self.db:
259
+ if row["doc_type"] is None:
260
+ self.db.execute(
261
+ "UPDATE attachments SET doc_type=? WHERE id=?",
262
+ (row["label"], row["attachment_id"]),
263
+ )
264
+ outcome = "applied"
265
+ else:
266
+ outcome = f"kept-existing:{row['doc_type']}"
267
+ self.db.execute(
268
+ "UPDATE classifications SET applied=1, applied_by=?, applied_role=?,"
269
+ " applied_at=? WHERE id=?",
270
+ (by, role, utcnow(), class_id),
271
+ )
272
+ return outcome
273
+
274
+ def attachments_for(self, msg_id: int) -> list[sqlite3.Row]:
275
+ return self.db.execute(
276
+ "SELECT * FROM attachments WHERE message_id=? ORDER BY id", (msg_id,)
277
+ ).fetchall()
278
+
279
+ # -- stats: queue health, not people scores --------------------------
280
+ def stats(self, days: int = 7) -> dict:
281
+ cutoff = f"-{days} days"
282
+ q = self.db.execute
283
+ ingested = q(
284
+ "SELECT COUNT(*) n FROM messages WHERE fetched_at >= datetime('now', ?)",
285
+ (cutoff,),
286
+ ).fetchone()["n"]
287
+ by_status = {r["status"]: r["n"] for r in q(
288
+ "SELECT status, COUNT(*) n FROM messages"
289
+ " WHERE fetched_at >= datetime('now', ?) GROUP BY status", (cutoff,))}
290
+ holds_by_gate = {r["gate_id"]: r["n"] for r in q(
291
+ "SELECT gate_id, COUNT(DISTINCT message_id) n FROM findings"
292
+ " WHERE severity='fail' AND created_at >= datetime('now', ?)"
293
+ " GROUP BY gate_id ORDER BY n DESC", (cutoff,))}
294
+ notices_by_type = {r["notice_type"]: r["n"] for r in q(
295
+ "SELECT notice_type, COUNT(*) n FROM notices"
296
+ " WHERE created_at >= datetime('now', ?) GROUP BY notice_type", (cutoff,))}
297
+ drift = q(
298
+ "SELECT COUNT(DISTINCT message_id) n FROM findings"
299
+ " WHERE gate_id='notice-parser' AND severity='fail'"
300
+ " AND created_at >= datetime('now', ?)", (cutoff,),
301
+ ).fetchone()["n"]
302
+ release = q(
303
+ "SELECT AVG((julianday(a.created_at) - julianday(m.fetched_at)) * 24) h"
304
+ " FROM approvals a JOIN messages m ON m.id = a.message_id"
305
+ " WHERE a.created_at >= datetime('now', ?)", (cutoff,),
306
+ ).fetchone()["h"]
307
+ approvals_by_role = {r["role"]: r["n"] for r in q(
308
+ "SELECT role, COUNT(*) n FROM approvals"
309
+ " WHERE created_at >= datetime('now', ?) GROUP BY role", (cutoff,))}
310
+ classifications = {r["k"]: r["n"] for r in q(
311
+ "SELECT CASE WHEN applied=0 THEN 'open' ELSE 'applied' END k, COUNT(*) n"
312
+ " FROM classifications WHERE created_at >= datetime('now', ?)"
313
+ " GROUP BY k", (cutoff,))}
314
+ return {
315
+ "days": days,
316
+ "ingested": ingested,
317
+ "by_status": by_status,
318
+ "holds_by_gate": holds_by_gate,
319
+ "notices_by_type": notices_by_type,
320
+ "template_drift_messages": drift,
321
+ "avg_hours_to_release": round(release, 1) if release is not None else None,
322
+ "approvals_by_role": approvals_by_role,
323
+ "classifications": classifications,
324
+ }
325
+
326
+ # -- imap cursor -----------------------------------------------------
327
+ def imap_cursor(self, mailbox: str) -> tuple[int | None, int]:
328
+ row = self.db.execute(
329
+ "SELECT uidvalidity, last_uid FROM imap_state WHERE mailbox=?", (mailbox,)
330
+ ).fetchone()
331
+ return (row["uidvalidity"], row["last_uid"]) if row else (None, 0)
332
+
333
+ def set_imap_cursor(self, mailbox: str, uidvalidity: int | None, last_uid: int) -> None:
334
+ with self.db:
335
+ self.db.execute(
336
+ "INSERT INTO imap_state(mailbox, uidvalidity, last_uid) VALUES(?,?,?)"
337
+ " ON CONFLICT(mailbox) DO UPDATE SET uidvalidity=excluded.uidvalidity,"
338
+ " last_uid=excluded.last_uid",
339
+ (mailbox, uidvalidity, last_uid),
340
+ )