1"""The comms bus: one workspace-anchored root with lane-separated storage.
3Proposal V1 §3, verbatim layout::
5 oc_chat/.option_c/comms/
6 outbound/ # the plan-219 durable outbox, relocated here
8 telegram/ # operator replies, one JSON file per message
10 worker-reports/ # created by plan 231, not here
11 audit/ # hash-only audit trail across all lanes
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).
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.
24- oc_connect.workspace (comms_root - the workspace-anchored root of the bus)
25- oc_diagnostics (_dbg structured logger)
27from __future__
import annotations
32import secrets
as _secrets
33from collections.abc
import Callable, Iterable
34from dataclasses
import dataclass
35from datetime
import datetime, timezone
36from pathlib
import Path
38from oc_diagnostics
import _dbg
40from .workspace
import comms_root
44_CROCKFORD =
"0123456789ABCDEFGHJKMNPQRSTVWXYZ"
46OUTBOX_FILENAME =
"outbox.json"
47AUDIT_FILENAME =
"audit.jsonl"
51 """A one-shot state migration could not be completed and verified."""
54def _encode(value: int, length: int) -> str:
56 for _
in range(length):
57 value, remainder = divmod(value, 32)
58 digits.append(_CROCKFORD[remainder])
59 return "".join(reversed(digits))
62def new_ulid(now: datetime, *, entropy: bytes |
None =
None) -> str:
63 """Return a 26-character ULID: 48 bits of time, 80 bits of randomness.
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`.
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)
75 raise ValueError(
"ULID entropy must be exactly ten bytes")
76 return _encode(milliseconds, 10) +
_encode(int.from_bytes(raw,
"big"), 16)
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")
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"
94 """Return the lowercase SHA-256 digest of raw file bytes."""
95 return hashlib.sha256(payload).hexdigest()
98@dataclass(frozen=True)
100 """One verified relocation: identical bytes at a new path."""
110@dataclass(frozen=True)
112 """What the one-shot move did, in a form a ready report can quote."""
114 moved: tuple[MigratedFile, ...]
115 skipped: tuple[str, ...]
118 """Return an ASCII one-line-per-file summary for operator reports."""
120 return "comms migration: nothing to move"
122 f
"{entry.name}: {entry.record_count} records, "
123 f
"{entry.byte_count} bytes, sha256={entry.sha256}"
124 for entry
in self.
moved
126 return "\n".join(lines)
130 destination.parent.mkdir(parents=
True, exist_ok=
True)
131 temporary = destination.with_name(
132 f
".tmp-{destination.name}-{_secrets.token_hex(8)}"
135 with temporary.open(
"wb")
as handle:
136 handle.write(payload)
138 os.fsync(handle.fileno())
139 os.replace(temporary, destination)
141 if temporary.exists():
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__})"
155 if name.endswith(
".jsonl"):
156 return sum(1
for line
in text.splitlines()
if line.strip())
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__})"
163 items = document.get(
"items")
if isinstance(document, dict)
else None
164 return len(items)
if isinstance(items, list)
else 0
168 """Resolve, create, and populate the comms bus for one workspace."""
170 def __init__(self, root: str | Path |
None =
None) ->
None:
171 self.
root = Path(comms_root(root))
175 """Return the outbound lane directory."""
176 return self.
root /
"outbound"
180 """Return the Telegram inbound lane directory."""
181 return self.
root /
"inbound" /
"telegram"
185 """Return the local lane root; plan 231 creates ``worker-reports/``."""
186 return self.
root /
"lanes"
190 """Return the worker-report lane directory (proposal V1 §5).
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.
202 """Return the session-alert lane directory (plan 237 / CAPS-01).
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.
213 """Return the cross-lane, hash-only audit directory."""
214 return self.
root /
"audit"
218 """Return the durable outbox file inside the outbound lane."""
223 """Return the append-only audit file."""
228 """Return the inbound transport cursor; a dotfile, never a message."""
232 """Return every directory the §3 layout requires, in creation order."""
236 """Create the full layout; already-present directories are left alone."""
239 directory.mkdir(parents=
True, exist_ok=
True)
240 _dbg(
"OC_CONNECT.COMMS", func, f
"comms layout ready at {self.root}", 2)
243 def _plan(self, legacy_dir: Path) -> tuple[tuple[str, Path, Path], ...]:
245 (OUTBOX_FILENAME, legacy_dir / OUTBOX_FILENAME, self.
outbox_path),
246 (AUDIT_FILENAME, legacy_dir / AUDIT_FILENAME, self.
audit_path),
251 legacy_dir: str | Path,
253 _write: Callable[[Path, bytes],
None] = _atomic_write,
254 ) -> MigrationReport:
255 """Move plan-219 outbox and audit state in, verifying every byte.
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.
263 *_write* is injected only so the negative control can prove the
264 verification step can fail; production callers never pass it.
266 func =
"migrate_from"
267 legacy = Path(legacy_dir)
268 moved: list[MigratedFile] = []
269 skipped: list[str] = []
271 for name, source, destination
in self.
_plan(legacy):
272 if not source.is_file():
275 payload = source.read_bytes()
277 if destination.exists():
283 f
"refusing to migrate {name}: {destination} already holds "
284 "different content; resolve the two files by hand"
287 _write(destination, payload)
288 written = destination.read_bytes()
291 f
"migration verification failed for {name}: {destination} "
292 f
"does not match the source digest; {source} was left in place"
299 destination=destination,
301 byte_count=len(payload),
302 record_count=records,
308 f
"migrated {name} records={records} bytes={len(payload)} "
315 """Return stored inbound message files in arrival (filename) order."""
320 if not directory.is_dir():
322 candidates: Iterable[Path] = directory.glob(
"*.json")
326 for path
in candidates
327 if path.is_file()
and not path.name.startswith(
".")
336 "CommsMigrationError",
tuple[tuple[str, Path, Path],...] _plan(self, Path legacy_dir)
Path session_alerts_dir(self)
tuple[Path,...] message_paths(self)
Path worker_reports_dir(self)
MigrationReport migrate_from(self, str|Path legacy_dir, *, Callable[[Path, bytes], None] _write=_atomic_write)
None __init__(self, str|Path|None root=None)
tuple[Path,...] initialise(self)
tuple[Path,...] directories(self)
str content_digest(bytes payload)
str new_ulid(datetime now, *, bytes|None entropy=None)
str _encode(int value, int length)
str utc_stamp(datetime now)
tuple[Path,...] _message_paths(Path directory)
str message_filename(datetime now, str source, str ulid)
None _atomic_write(Path destination, bytes payload)
int _count_records(str name, bytes payload)