oc_connect
Loading...
Searching...
No Matches
comms.py
Go to the documentation of this file.
1"""The comms bus: one workspace-anchored root with lane-separated storage.
2
3Proposal V1 §3, verbatim layout::
4
5 oc_chat/.option_c/comms/
6 outbound/ # the plan-219 durable outbox, relocated here
7 inbound/
8 telegram/ # operator replies, one JSON file per message
9 lanes/
10 worker-reports/ # created by plan 231, not here
11 audit/ # hash-only audit trail across all lanes
12
13Lane separation is structural, not advisory: the directories exist, each
14consumer is handed only the directory it may touch, and every stored record
15names its lane, direction and risk class (§9's binding rule).
16
17The plan-219 state moves in through :meth:`CommsBus.migrate_from`, which is a
18*verified* move: hash the source, write the destination, re-read and re-hash the
19destination, and only then unlink the source. A mismatch aborts with the source
20still in place, so a failed migration loses nothing.
21
22Dependencies
23------------
24- oc_connect.workspace (comms_root - the workspace-anchored root of the bus)
25- oc_diagnostics (_dbg structured logger)
26"""
27from __future__ import annotations
28
29import hashlib
30import json
31import os
32import secrets as _secrets
33from collections.abc import Callable, Iterable
34from dataclasses import dataclass
35from datetime import datetime, timezone
36from pathlib import Path
37
38from oc_diagnostics import _dbg
39
40from .workspace import comms_root
41
42#: Crockford base32, the ULID alphabet: no I, L, O or U.
43# OCT-LINT: disable=no_hardcoded_secrets reason="Public standard encoding alphabet; its 5.00 bits/char is the arithmetic maximum for any 32-distinct-character string and carries no credential. Operator-ruled 2026-09-01 (plan 230 W1 escalation)"
44_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
45
46OUTBOX_FILENAME = "outbox.json"
47AUDIT_FILENAME = "audit.jsonl"
48
49
50class CommsMigrationError(RuntimeError):
51 """A one-shot state migration could not be completed and verified."""
52
53
54def _encode(value: int, length: int) -> str:
55 digits = []
56 for _ in range(length):
57 value, remainder = divmod(value, 32)
58 digits.append(_CROCKFORD[remainder])
59 return "".join(reversed(digits))
60
61
62def new_ulid(now: datetime, *, entropy: bytes | None = None) -> str:
63 """Return a 26-character ULID: 48 bits of time, 80 bits of randomness.
64
65 Time-first encoding means lexical order is arrival order, which is what
66 makes the flood bound's oldest-first rule a plain directory sort.
67 *entropy* exists so tests can pin the random half; production leaves it
68 ``None`` and draws from :mod:`secrets`.
69 """
70 if now.tzinfo is None:
71 now = now.replace(tzinfo=timezone.utc)
72 milliseconds = int(now.astimezone(timezone.utc).timestamp() * 1000)
73 raw = entropy if entropy is not None else _secrets.token_bytes(10)
74 if len(raw) != 10:
75 raise ValueError("ULID entropy must be exactly ten bytes")
76 return _encode(milliseconds, 10) + _encode(int.from_bytes(raw, "big"), 16)
77
78
79def utc_stamp(now: datetime) -> str:
80 """Return a filename-safe UTC stamp; colons are illegal on Windows."""
81 if now.tzinfo is None:
82 now = now.replace(tzinfo=timezone.utc)
83 return now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
84
85
86def message_filename(now: datetime, source: str, ulid: str) -> str:
87 """Return the ``<utc-stamp>_<source>_<ulid>.json`` name from §3."""
88 if not source.strip() or "_" in source:
89 raise ValueError("message source must be non-blank and contain no underscore")
90 return f"{utc_stamp(now)}_{source}_{ulid}.json"
91
92
93def content_digest(payload: bytes) -> str:
94 """Return the lowercase SHA-256 digest of raw file bytes."""
95 return hashlib.sha256(payload).hexdigest()
96
97
98@dataclass(frozen=True)
100 """One verified relocation: identical bytes at a new path."""
101
102 name: str
103 source: Path
104 destination: Path
105 sha256: str
106 byte_count: int
107 record_count: int
108
109
110@dataclass(frozen=True)
112 """What the one-shot move did, in a form a ready report can quote."""
113
114 moved: tuple[MigratedFile, ...]
115 skipped: tuple[str, ...]
116
117 def summary(self) -> str:
118 """Return an ASCII one-line-per-file summary for operator reports."""
119 if not self.moved:
120 return "comms migration: nothing to move"
121 lines = [
122 f"{entry.name}: {entry.record_count} records, "
123 f"{entry.byte_count} bytes, sha256={entry.sha256}"
124 for entry in self.moved
125 ]
126 return "\n".join(lines)
127
128
129def _atomic_write(destination: Path, payload: bytes) -> None:
130 destination.parent.mkdir(parents=True, exist_ok=True)
131 temporary = destination.with_name(
132 f".tmp-{destination.name}-{_secrets.token_hex(8)}"
133 )
134 try:
135 with temporary.open("wb") as handle:
136 handle.write(payload)
137 handle.flush()
138 os.fsync(handle.fileno())
139 os.replace(temporary, destination)
140 finally:
141 if temporary.exists():
142 try:
143 temporary.unlink()
144 except OSError:
145 pass
146
147
148def _count_records(name: str, payload: bytes) -> int:
149 try:
150 text = payload.decode("utf-8")
151 except UnicodeDecodeError as exc:
153 f"refusing to migrate {name}: it is not UTF-8 ({type(exc).__name__})"
154 ) from None
155 if name.endswith(".jsonl"):
156 return sum(1 for line in text.splitlines() if line.strip())
157 try:
158 document = json.loads(text)
159 except json.JSONDecodeError as exc:
161 f"refusing to migrate {name}: it is not valid JSON ({type(exc).__name__})"
162 ) from None
163 items = document.get("items") if isinstance(document, dict) else None
164 return len(items) if isinstance(items, list) else 0
165
166
168 """Resolve, create, and populate the comms bus for one workspace."""
169
170 def __init__(self, root: str | Path | None = None) -> None:
171 self.root = Path(comms_root(root))
172
173 @property
174 def outbound_dir(self) -> Path:
175 """Return the outbound lane directory."""
176 return self.root / "outbound"
177
178 @property
179 def inbound_dir(self) -> Path:
180 """Return the Telegram inbound lane directory."""
181 return self.root / "inbound" / "telegram"
182
183 @property
184 def lanes_dir(self) -> Path:
185 """Return the local lane root; plan 231 creates ``worker-reports/``."""
186 return self.root / "lanes"
187
188 @property
189 def worker_reports_dir(self) -> Path:
190 """Return the worker-report lane directory (proposal V1 §5).
191
192 Deliberately absent from :meth:`directories`: the lane is created by
193 whoever first writes or reads it, because the worker-side helper must
194 work in a workspace where the bus has never been initialised, and an
195 orchestrator that has never received a worker report should not find a
196 directory implying it has.
197 """
198 return self.lanes_dir / "worker-reports"
199
200 @property
201 def session_alerts_dir(self) -> Path:
202 """Return the session-alert lane directory (plan 237 / CAPS-01).
203
204 Absent from :meth:`directories` for the same reason as the worker-report
205 lane above: the product-side writer must work in a workspace whose bus
206 has never been initialised, and an operator who has never had a cost
207 alert should not find a directory implying otherwise.
208 """
209 return self.lanes_dir / "session-alerts"
210
211 @property
212 def audit_dir(self) -> Path:
213 """Return the cross-lane, hash-only audit directory."""
214 return self.root / "audit"
215
216 @property
217 def outbox_path(self) -> Path:
218 """Return the durable outbox file inside the outbound lane."""
219 return self.outbound_dir / OUTBOX_FILENAME
220
221 @property
222 def audit_path(self) -> Path:
223 """Return the append-only audit file."""
224 return self.audit_dir / AUDIT_FILENAME
225
226 @property
227 def cursor_path(self) -> Path:
228 """Return the inbound transport cursor; a dotfile, never a message."""
229 return self.inbound_dir / ".cursor.json"
230
231 def directories(self) -> tuple[Path, ...]:
232 """Return every directory the §3 layout requires, in creation order."""
233 return (self.outbound_dir, self.inbound_dir, self.lanes_dir, self.audit_dir)
234
235 def initialise(self) -> tuple[Path, ...]:
236 """Create the full layout; already-present directories are left alone."""
237 func = "initialise"
238 for directory in self.directories():
239 directory.mkdir(parents=True, exist_ok=True)
240 _dbg("OC_CONNECT.COMMS", func, f"comms layout ready at {self.root}", 2)
241 return self.directories()
242
243 def _plan(self, legacy_dir: Path) -> tuple[tuple[str, Path, Path], ...]:
244 return (
245 (OUTBOX_FILENAME, legacy_dir / OUTBOX_FILENAME, self.outbox_path),
246 (AUDIT_FILENAME, legacy_dir / AUDIT_FILENAME, self.audit_path),
247 )
248
250 self,
251 legacy_dir: str | Path,
252 *,
253 _write: Callable[[Path, bytes], None] = _atomic_write,
254 ) -> MigrationReport:
255 """Move plan-219 outbox and audit state in, verifying every byte.
256
257 The move is one-shot and idempotent: a source that is already gone, or
258 a destination that already holds byte-identical content, is reported as
259 skipped rather than repeated. A destination holding *different* content
260 aborts the whole migration — silently choosing a winner between two
261 divergent audit trails is never the right call.
262
263 *_write* is injected only so the negative control can prove the
264 verification step can fail; production callers never pass it.
265 """
266 func = "migrate_from"
267 legacy = Path(legacy_dir)
268 moved: list[MigratedFile] = []
269 skipped: list[str] = []
270 self.initialise()
271 for name, source, destination in self._plan(legacy):
272 if not source.is_file():
273 skipped.append(name)
274 continue
275 payload = source.read_bytes()
276 digest = content_digest(payload)
277 if destination.exists():
278 if content_digest(destination.read_bytes()) == digest:
279 source.unlink()
280 skipped.append(name)
281 continue
283 f"refusing to migrate {name}: {destination} already holds "
284 "different content; resolve the two files by hand"
285 )
286 records = _count_records(name, payload)
287 _write(destination, payload)
288 written = destination.read_bytes()
289 if content_digest(written) != digest:
291 f"migration verification failed for {name}: {destination} "
292 f"does not match the source digest; {source} was left in place"
293 )
294 source.unlink()
295 moved.append(
297 name=name,
298 source=source,
299 destination=destination,
300 sha256=digest,
301 byte_count=len(payload),
302 record_count=records,
303 )
304 )
305 _dbg(
306 "OC_CONNECT.COMMS",
307 func,
308 f"migrated {name} records={records} bytes={len(payload)} "
309 f"sha256={digest}",
310 2,
311 )
312 return MigrationReport(moved=tuple(moved), skipped=tuple(skipped))
313
314 def message_paths(self) -> tuple[Path, ...]:
315 """Return stored inbound message files in arrival (filename) order."""
316 return _message_paths(self.inbound_dir)
317
318
319def _message_paths(directory: Path) -> tuple[Path, ...]:
320 if not directory.is_dir():
321 return ()
322 candidates: Iterable[Path] = directory.glob("*.json")
323 return tuple(
324 sorted(
325 path
326 for path in candidates
327 if path.is_file() and not path.name.startswith(".")
328 )
329 )
330
331
332__all__ = [
333 "AUDIT_FILENAME",
334 "OUTBOX_FILENAME",
335 "CommsBus",
336 "CommsMigrationError",
337 "MigratedFile",
338 "MigrationReport",
339 "content_digest",
340 "message_filename",
341 "new_ulid",
342 "utc_stamp",
343]
tuple[tuple[str, Path, Path],...] _plan(self, Path legacy_dir)
Definition comms.py:243
Path session_alerts_dir(self)
Definition comms.py:201
tuple[Path,...] message_paths(self)
Definition comms.py:314
Path worker_reports_dir(self)
Definition comms.py:189
Path cursor_path(self)
Definition comms.py:227
MigrationReport migrate_from(self, str|Path legacy_dir, *, Callable[[Path, bytes], None] _write=_atomic_write)
Definition comms.py:254
None __init__(self, str|Path|None root=None)
Definition comms.py:170
tuple[Path,...] initialise(self)
Definition comms.py:235
tuple[Path,...] directories(self)
Definition comms.py:231
str content_digest(bytes payload)
Definition comms.py:93
str new_ulid(datetime now, *, bytes|None entropy=None)
Definition comms.py:62
str _encode(int value, int length)
Definition comms.py:54
str utc_stamp(datetime now)
Definition comms.py:79
tuple[Path,...] _message_paths(Path directory)
Definition comms.py:319
str message_filename(datetime now, str source, str ulid)
Definition comms.py:86
None _atomic_write(Path destination, bytes payload)
Definition comms.py:129
int _count_records(str name, bytes payload)
Definition comms.py:148