oc_connect
Loading...
Searching...
No Matches
inbox.py
Go to the documentation of this file.
1"""The inbox: one JSON file per message, with states and retention.
2
3Proposal V1 §4 (consumption) and §6/§10.1 (retention). Messages move
4``unread -> read -> acted``; acting records *which decision* consumed the
5message, so an approval can be traced back from the thing it authorised.
6
7Retention is the operator's ruling: an acted message prunes one year after it
8was acted on, and its audit hash survives the prune. The audit trail is the
9durable record; the body is not.
10
11Dependencies
12------------
13- oc_connect.audit (AuditStore, MAX_DECISION_REF, timestamp, utc_now - audits read, acted and pruned transitions)
14- oc_connect.comms (message_filename - the bus filename convention for a stored message)
15- oc_diagnostics (_dbg structured logger)
16- option_c_connector_contracts (InboundMessage, InboundState - the stored message model and its states)
17"""
18from __future__ import annotations
19
20import json
21import os
22import secrets as _secrets
23from collections.abc import Callable
24from datetime import datetime, timedelta, timezone
25from pathlib import Path
26
27from oc_diagnostics import _dbg
28from option_c_connector_contracts import InboundMessage, InboundState
29
30from ..audit import MAX_DECISION_REF, AuditStore, timestamp, utc_now
31from ..comms import message_filename
32
33RETENTION_DAYS = 365
34
35
36class InboxStoreError(ValueError):
37 """A stored inbound message is unreadable or malformed."""
38
39
40def _parse_time(value: str) -> datetime:
41 return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
42
43
45 """Durable per-message storage for one inbound lane directory."""
46
48 self,
49 directory: str | Path,
50 *,
51 audit: AuditStore | None = None,
52 now: Callable[[], datetime] = utc_now,
53 source: str = "telegram",
54 ) -> None:
55 self.directory = Path(directory)
56 self.audit = audit
57 self._now = now
58 self.source = source
59
60 def _write(self, path: Path, message: InboundMessage) -> None:
61 self.directory.mkdir(parents=True, exist_ok=True)
62 temporary = path.with_name(f".tmp-{path.name}-{_secrets.token_hex(8)}")
63 payload = json.dumps(message.model_dump(mode="json"), indent=2, sort_keys=True)
64 try:
65 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
66 handle.write(payload)
67 handle.write("\n")
68 handle.flush()
69 os.fsync(handle.fileno())
70 os.replace(temporary, path)
71 finally:
72 if temporary.exists():
73 try:
74 temporary.unlink()
75 except OSError:
76 pass
77
78 def paths(self) -> tuple[Path, ...]:
79 """Return stored message files in arrival order, ignoring dotfiles."""
80 if not self.directory.is_dir():
81 return ()
82 return tuple(
83 sorted(
84 path
85 for path in self.directory.glob("*.json")
86 if path.is_file() and not path.name.startswith(".")
87 )
88 )
89
90 def _read(self, path: Path) -> InboundMessage:
91 try:
92 return InboundMessage.model_validate_json(
93 path.read_text(encoding="utf-8")
94 )
95 except (OSError, UnicodeDecodeError, ValueError) as exc:
96 raise InboxStoreError(
97 f"could not parse inbound message at {path} ({type(exc).__name__})"
98 ) from None
99
100 def store(self, message: InboundMessage) -> Path:
101 """Persist one newly-arrived message and return its path.
102
103 An id collision is refused rather than overwritten. Two ULIDs collide
104 only on a same-millisecond, same-80-bit-entropy draw, so in production
105 this never fires - but the failure mode it prevents is an inbox that
106 silently loses an operator reply, which must never be the quiet path.
107 """
108 received = _parse_time(message.received_at_utc)
109 path = self.directory / message_filename(
110 received, self.source, message.message_id
111 )
112 if path.exists():
113 raise InboxStoreError(
114 f"refusing to overwrite stored inbound message "
115 f"{message.message_id!r} at {path}"
116 )
117 self._write(path, message)
118 _dbg(
119 "OC_CONNECT.INBOX",
120 "store",
121 f"stored message={message.message_id} lane={message.lane} "
122 f"classification={message.classification}",
123 2,
124 )
125 return path
126
128 self,
129 *,
130 state: InboundState | None = None,
131 ) -> tuple[InboundMessage, ...]:
132 """Return stored messages in arrival order, optionally by state."""
133 messages = tuple(self._read(path) for path in self.paths())
134 if state is None:
135 return messages
136 return tuple(message for message in messages if message.state == state)
137
138 def _locate(self, message_id: str) -> Path:
139 suffix = f"_{message_id}.json"
140 for path in self.paths():
141 if path.name.endswith(suffix):
142 return path
143 raise KeyError(f"inbound message {message_id!r} is not in the inbox")
144
145 def get(self, message_id: str) -> InboundMessage:
146 """Return one stored message or fail with its public id."""
147 return self._read(self._locate(message_id))
148
149 def _audit(self, message: InboundMessage, outcome: str) -> None:
150 if self.audit is None:
151 return
152 decision = message.consumed_by_decision
153 self.audit.append(
154 connector=message.connector,
155 grant_id=None,
156 destination=message.chat_id,
157 outcome=outcome, # type: ignore[arg-type]
158 digest=message.content_hash,
159 idempotency_key=message.message_id,
160 attempts=0,
161 lane=message.lane,
162 direction=message.direction,
163 risk_class=message.risk_class,
164 decision_ref=decision[:MAX_DECISION_REF] if decision else None,
165 )
166
167 def mark_read(self, message_id: str) -> InboundMessage:
168 """Move one unread message to ``read``; re-reading is idempotent."""
169 path = self._locate(message_id)
170 message = self._read(path)
171 if message.state != "unread":
172 return message
173 updated = message.model_copy(
174 update={"state": "read", "read_at_utc": timestamp(self._now())}
175 )
176 self._write(path, updated)
177 self._audit(updated, "read")
178 return updated
179
180 def mark_acted(self, message_id: str, decision: str) -> InboundMessage:
181 """Record which decision consumed one message (§4 consumption)."""
182 if not decision.strip():
183 raise ValueError("acting on a message requires the consuming decision")
184 path = self._locate(message_id)
185 message = self._read(path)
186 if message.state == "acted":
187 raise ValueError(
188 f"inbound message {message_id!r} was already consumed by "
189 f"{message.consumed_by_decision!r}"
190 )
191 stamped = timestamp(self._now())
192 updated = message.model_copy(
193 update={
194 "state": "acted",
195 "read_at_utc": message.read_at_utc or stamped,
196 "acted_at_utc": stamped,
197 "consumed_by_decision": decision.strip(),
198 }
199 )
200 self._write(path, updated)
201 self._audit(updated, "acted")
202 _dbg(
203 "OC_CONNECT.INBOX",
204 "mark_acted",
205 f"message={message_id} consumed by decision (length="
206 f"{len(decision.strip())})",
207 2,
208 )
209 return updated
210
211 def count_since(self, cutoff: datetime) -> int:
212 """Return how many messages arrived at or after *cutoff*."""
213 return sum(
214 1
215 for message in self.list_messages()
216 if _parse_time(message.received_at_utc) >= cutoff
217 )
218
219 def prune(self, *, retention_days: int = RETENTION_DAYS) -> tuple[str, ...]:
220 """Delete acted messages older than the retention window.
221
222 Only ``acted`` messages are eligible: an unread or read message is
223 still work in progress, however old it is. The audit record and its
224 content hash are untouched, so a pruned message stays provable.
225 """
226 if retention_days < 1:
227 raise ValueError("retention_days must be at least one")
228 cutoff = self._now().astimezone(timezone.utc) - timedelta(days=retention_days)
229 pruned: list[str] = []
230 for path in self.paths():
231 message = self._read(path)
232 if message.state != "acted" or message.acted_at_utc is None:
233 continue
234 if _parse_time(message.acted_at_utc) > cutoff:
235 continue
236 self._audit(message, "pruned")
237 path.unlink()
238 pruned.append(message.message_id)
239 if pruned:
240 _dbg(
241 "OC_CONNECT.INBOX",
242 "prune",
243 f"pruned {len(pruned)} acted messages past {retention_days} days",
244 2,
245 )
246 return tuple(pruned)
247
248
249__all__ = ["RETENTION_DAYS", "InboxStore", "InboxStoreError"]
InboundMessage mark_acted(self, str message_id, str decision)
Definition inbox.py:180
Path store(self, InboundMessage message)
Definition inbox.py:100
InboundMessage get(self, str message_id)
Definition inbox.py:145
InboundMessage _read(self, Path path)
Definition inbox.py:90
Path _locate(self, str message_id)
Definition inbox.py:138
None _audit(self, InboundMessage message, str outcome)
Definition inbox.py:149
None __init__(self, str|Path directory, *, AuditStore|None audit=None, Callable[[], datetime] now=utc_now, str source="telegram")
Definition inbox.py:54
tuple[str,...] prune(self, *, int retention_days=RETENTION_DAYS)
Definition inbox.py:219
None _write(self, Path path, InboundMessage message)
Definition inbox.py:60
int count_since(self, datetime cutoff)
Definition inbox.py:211
tuple[InboundMessage,...] list_messages(self, *, InboundState|None state=None)
Definition inbox.py:131
InboundMessage mark_read(self, str message_id)
Definition inbox.py:167
tuple[Path,...] paths(self)
Definition inbox.py:78
datetime _parse_time(str value)
Definition inbox.py:40