1"""The inbox: one JSON file per message, with states and retention.
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.
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.
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)
18from __future__
import annotations
22import secrets
as _secrets
23from collections.abc
import Callable
24from datetime
import datetime, timedelta, timezone
25from pathlib
import Path
27from oc_diagnostics
import _dbg
28from option_c_connector_contracts
import InboundMessage, InboundState
30from ..audit
import MAX_DECISION_REF, AuditStore, timestamp, utc_now
31from ..comms
import message_filename
37 """A stored inbound message is unreadable or malformed."""
41 return datetime.fromisoformat(value.replace(
"Z",
"+00:00")).astimezone(timezone.utc)
45 """Durable per-message storage for one inbound lane directory."""
49 directory: str | Path,
51 audit: AuditStore |
None =
None,
52 now: Callable[[], datetime] = utc_now,
53 source: str =
"telegram",
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)
65 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
69 os.fsync(handle.fileno())
70 os.replace(temporary, path)
72 if temporary.exists():
78 def paths(self) -> tuple[Path, ...]:
79 """Return stored message files in arrival order, ignoring dotfiles."""
86 if path.is_file()
and not path.name.startswith(
".")
90 def _read(self, path: Path) -> InboundMessage:
92 return InboundMessage.model_validate_json(
93 path.read_text(encoding=
"utf-8")
95 except (OSError, UnicodeDecodeError, ValueError)
as exc:
97 f
"could not parse inbound message at {path} ({type(exc).__name__})"
100 def store(self, message: InboundMessage) -> Path:
101 """Persist one newly-arrived message and return its path.
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.
109 path = self.
directory / message_filename(
110 received, self.
source, message.message_id
114 f
"refusing to overwrite stored inbound message "
115 f
"{message.message_id!r} at {path}"
117 self.
_write(path, message)
121 f
"stored message={message.message_id} lane={message.lane} "
122 f
"classification={message.classification}",
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())
136 return tuple(message
for message
in messages
if message.state == state)
139 suffix = f
"_{message_id}.json"
140 for path
in self.
paths():
141 if path.name.endswith(suffix):
143 raise KeyError(f
"inbound message {message_id!r} is not in the inbox")
145 def get(self, message_id: str) -> InboundMessage:
146 """Return one stored message or fail with its public id."""
149 def _audit(self, message: InboundMessage, outcome: str) ->
None:
150 if self.
audit is None:
152 decision = message.consumed_by_decision
154 connector=message.connector,
156 destination=message.chat_id,
158 digest=message.content_hash,
159 idempotency_key=message.message_id,
162 direction=message.direction,
163 risk_class=message.risk_class,
164 decision_ref=decision[:MAX_DECISION_REF]
if decision
else None,
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":
173 updated = message.model_copy(
174 update={
"state":
"read",
"read_at_utc": timestamp(self.
_now())}
176 self.
_write(path, updated)
177 self.
_audit(updated,
"read")
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":
188 f
"inbound message {message_id!r} was already consumed by "
189 f
"{message.consumed_by_decision!r}"
191 stamped = timestamp(self.
_now())
192 updated = message.model_copy(
195 "read_at_utc": message.read_at_utc
or stamped,
196 "acted_at_utc": stamped,
197 "consumed_by_decision": decision.strip(),
200 self.
_write(path, updated)
201 self.
_audit(updated,
"acted")
205 f
"message={message_id} consumed by decision (length="
206 f
"{len(decision.strip())})",
212 """Return how many messages arrived at or after *cutoff*."""
219 def prune(self, *, retention_days: int = RETENTION_DAYS) -> tuple[str, ...]:
220 """Delete acted messages older than the retention window.
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.
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:
236 self.
_audit(message,
"pruned")
238 pruned.append(message.message_id)
243 f
"pruned {len(pruned)} acted messages past {retention_days} days",
249__all__ = [
"RETENTION_DAYS",
"InboxStore",
"InboxStoreError"]
InboundMessage mark_acted(self, str message_id, str decision)
Path store(self, InboundMessage message)
InboundMessage get(self, str message_id)
InboundMessage _read(self, Path path)
Path _locate(self, str message_id)
None _audit(self, InboundMessage message, str outcome)
None __init__(self, str|Path directory, *, AuditStore|None audit=None, Callable[[], datetime] now=utc_now, str source="telegram")
tuple[str,...] prune(self, *, int retention_days=RETENTION_DAYS)
None _write(self, Path path, InboundMessage message)
int count_since(self, datetime cutoff)
tuple[InboundMessage,...] list_messages(self, *, InboundState|None state=None)
InboundMessage mark_read(self, str message_id)
tuple[Path,...] paths(self)
datetime _parse_time(str value)