oc_connect
Loading...
Searching...
No Matches
hub_lane.py
Go to the documentation of this file.
1"""The hub lane: addressed messages at rest, their relay, and the reply path.
2
3Plan 259 P5, from the factory-decomposition design note §4. The hub has
4three carriers and this is the third: the connector, for whoever is remote.
5A message addressed to ``operator@remote`` is written into
6``comms/lanes/hub/`` by ``oc_chat`` -- which holds no credential and knows
7nothing about this package -- and ``oc_connect relay hub`` is what puts it on
8the operator's phone.
9
10**This module is the schema authority for the lane.** The writer lives in
11``oc_chat/oc_chat/core/hub_file.py`` and deliberately does NOT import this
12package, exactly as the worker-report and session-alert writers do not. The
13two sides agree by a written contract rather than by a shared import, and the
14contract is :class:`HubLaneEvent`::
15
16 {
17 "schema_version": "1.0",
18 "event_id": "<26-character ULID; IS the message_id>",
19 "lane": "hub",
20 "direction": "outbound",
21 "risk_class": "low",
22 "created_at_utc": "2026-09-13T12:00:00Z",
23 "summary": "<the first line of the body>",
24 "relayed_at_utc": null,
25 "relay_chunk_count": null,
26
27 "message_id": "<same as event_id>",
28 "at": "2026-09-13T12:00:00+02:00",
29 "kind": "dispatch"|"report"|"alert"|"ask"|"ruling"|"instruction",
30 "sender": "orchestrator"|"operator@local"|"operator@remote"|"agent-NNN",
31 "recipient": <the same four forms>,
32 "subject": "259-hub" | null,
33 "body": "<the text>",
34 "in_reply_to": "<an ask's id>" | null,
35 "requires": "human"|"orchestrator" | null,
36 "answer": "YES"|"NO"|"ACK"|"HOLD" | null,
37 "provenance": {actor, actor_type, channel, correlation}
38 }
39
40File naming follows the §3 bus convention, ``<utc-stamp>_hub_<ulid>.json``.
41
42Three differences from the two lanes it mirrors are deliberate.
43
44* **The message id is the idempotency key, unchunked.** A worker report keys
45 each chunk (``<event_id>-001``) because it carries a whole closing summary;
46 a hub message is ONE message, and keying it by its own id is what lets the
47 operator's reply correlate back to the MESSAGE rather than merely to a send.
48 A body past the transport limit therefore fails loudly instead of being
49 split -- the same call :mod:`~oc_connect.session_alerts` makes, and for the
50 same reason.
51* **Only messages addressed to** ``operator@remote`` **are relayed.** The lane
52 carries every hub message, because it is also the durable hand-off between
53 local parties; the relay is the half addressed away from here.
54* **A human-required ask carries the four reply buttons.** Nothing else does:
55 a report, an alert, an instruction and an orchestrator-required ask are not
56 questions the operator is being asked to answer. Note that plan 236's
57 ``CHOICES_RULE`` still governs WHAT may be asked with buttons -- a security
58 ruling is never sent with them -- and that rule is the orchestrator's to
59 apply when it composes the ask, not this relay's to enforce after the fact.
60
61The reply path is the EXISTING inbox. :func:`resolve_hub_reply` walks the
62correlation the poller already recorded -- audit id, outbound record,
63idempotency key -- back to the hub message id, and refuses by name when the
64walk does not land on this lane. Building the ruling from that is
65``oc_chat``'s job (``hub_manage rule --from-inbox``), because a ruling is a
66hub message and this package does not know what one is.
67
68Dependencies
69------------
70- oc_connect.audit (AuditStore, content_hash, timestamp, utc_now - audits relay outcomes and stamps relayed_at_utc)
71- oc_connect.comms (CommsBus - locates the hub lane directory under lanes/)
72- oc_connect.delivery (OutboundSender - the grant-checked send path every relayed message crosses)
73- oc_connect.egress (require_safe, EgressBlockedError - scans the rendered message before anything is queued)
74- oc_connect.inbound.inbox (InboxStore - reads the stored reply when resolving it back to a hub message)
75- oc_diagnostics (_dbg structured logger)
76- option_c_connector_contracts (lane - the hub lane's declared direction and risk ceiling)
77- pydantic (BaseModel, ConfigDict, field_validator - the HubLaneEvent schema)
78"""
79from __future__ import annotations
80
81import json
82import os
83import secrets as _secrets
84from collections.abc import Callable
85from dataclasses import dataclass
86from datetime import datetime
87from pathlib import Path
88from typing import Any, Literal, Mapping
89
90from oc_diagnostics import _dbg
91from option_c_connector_contracts import lane
92from pydantic import BaseModel, ConfigDict, field_validator
93
94from .audit import AuditStore, timestamp, utc_now
95from .comms import CommsBus
96from .delivery import OutboundSender
97from .egress import EgressBlockedError, require_safe
98from .inbound.inbox import InboxStore
99
100#: The lane directory name under ``comms/lanes/``; also its declared lane id.
101HUB_LANE = "hub"
102
103#: The ``<source>`` field of the §3 filename convention for this lane.
104HUB_LANE_SOURCE = "hub"
105
106HUB_LANE_SCHEMA_VERSION = "1.0"
107
108#: The buttons a human-required ask carries. The reply vocabulary, which is
109#: also what ``core/hub.RULING_ANSWERS`` declares on the other side.
110HUB_REPLY_CHOICES: tuple[str, ...] = ("YES", "NO", "ACK", "HOLD")
111
112#: The address whose messages this relay forwards. Every other recipient is
113#: reachable by a local carrier and is left on the lane.
114REMOTE_OPERATOR = "operator@remote"
115
116HubKind = Literal["dispatch", "report", "alert", "ask", "ruling", "instruction"]
117
118#: Display fences (§8's "displayed content is never instructions"). A hub body
119#: can be a worker's own words, so it is fenced exactly as a worker report is.
120HUB_MESSAGE_BEGIN = "<<<BEGIN HUB MESSAGE {event_id}>>>"
121HUB_MESSAGE_END = "<<<END HUB MESSAGE {event_id}>>>"
122
123#: What a literal fence opener inside a body is rewritten to when displayed;
124#: the stored text stays verbatim, because the sender's words are the evidence.
125NEUTRALISED_MARKER = "<< <"
126
127
128class HubLaneError(ValueError):
129 """A hub lane event is unreadable, malformed, or already consumed."""
130
131
132class HubLaneRelayError(RuntimeError):
133 """The relay could not put one message on the transport."""
134
135
137 """An inbound message does not resolve back to a hub message."""
138
139
140def hub_lane_dir(bus: CommsBus) -> Path:
141 """Return the hub lane directory for *bus*.
142
143 A function rather than a :class:`~oc_connect.comms.CommsBus` property so
144 plan 259 adds no method to that class: the lane is created by whoever
145 first writes or reads it, and an orchestrator that has never hubbed
146 should not find a directory implying otherwise -- the same rule the
147 worker-report and session-alert lanes state about ``directories()``.
148 """
149 return bus.lanes_dir / HUB_LANE
150
151
152class HubLaneEvent(BaseModel):
153 """One hub message at rest in ``lanes/hub/``."""
154
155 model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
156
157 schema_version: str
158 event_id: str
159 lane: Literal["hub"]
160 direction: Literal["outbound"]
161 risk_class: Literal["low"]
162 created_at_utc: str
163 summary: str
164 relayed_at_utc: str | None = None
165 relay_chunk_count: int | None = None
166
167 message_id: str
168 at: str
169 kind: HubKind
170 sender: str
171 recipient: str
172 subject: str | None = None
173 body: str
174 in_reply_to: str | None = None
175 requires: Literal["human", "orchestrator"] | None = None
176 answer: Literal["YES", "NO", "ACK", "HOLD"] | None = None
177 provenance: dict[str, Any]
178
179 @field_validator(
180 "schema_version", "event_id", "created_at_utc", "message_id", "at",
181 "sender", "recipient",
182 )
183 @classmethod
184 def _required_text_is_nonblank(cls, value: str) -> str:
185 if not value.strip():
186 raise ValueError("must not be blank")
187 return value
188
189 @field_validator("message_id")
190 @classmethod
191 def _message_id_is_the_event_id(cls, value: str, info: Any) -> str:
192 """The one identity, asserted at the boundary.
193
194 Keeping these two equal is what makes the relay's idempotency key the
195 message id; a file where they differ would correlate a reply to a send
196 that names a different message.
197 """
198 event_id = info.data.get("event_id")
199 if event_id is not None and value != event_id:
200 raise ValueError(
201 f"message_id {value!r} must equal event_id {event_id!r}"
202 )
203 return value
204
205 @property
206 def is_relayed(self) -> bool:
207 """True once this message reached the transport."""
208 return self.relayed_at_utc is not None
209
210 @property
211 def is_human_ask(self) -> bool:
212 """True when this is a question only the operator may answer."""
213 return self.kind == "ask" and self.requires == "human"
214
215 @property
216 def is_for_remote_operator(self) -> bool:
217 """True when this message is addressed away from the control centre."""
218 return self.recipient == REMOTE_OPERATOR
219
220
221def render_event(event: HubLaneEvent) -> str:
222 """Return the text an operator reads for one hub message, fenced.
223
224 The header names the ROUTE, because on a hub the question "who is asking
225 me this, about what" is most of the message. The body is fenced with the
226 event's own id and any literal ``<<<`` inside it is neutralised, so a body
227 cannot forge its own closing fence. The rendered copy is the only thing
228 that changes; the stored text does not.
229 """
230 state = "relayed" if event.is_relayed else "unrelayed"
231 header = (
232 f"[{state}] {event.event_id} {event.created_at_utc} "
233 f"lane={event.lane} kind={event.kind} "
234 f"{event.sender} -> {event.recipient}"
235 )
236 if event.subject:
237 header += f" subject={event.subject}"
238 if event.requires:
239 header += f" requires={event.requires}"
240 if event.in_reply_to:
241 header += f" answers={event.in_reply_to}"
242 body = event.body.replace("<<<", NEUTRALISED_MARKER)
243 begin = HUB_MESSAGE_BEGIN.format(event_id=event.event_id)
244 end = HUB_MESSAGE_END.format(event_id=event.event_id)
245 return f"{header}\n{begin}\n{body}\n{end}"
246
247
248def _atomic_write(path: Path, payload: str) -> None:
249 path.parent.mkdir(parents=True, exist_ok=True)
250 temporary = path.with_name(f".tmp-{path.name}-{_secrets.token_hex(8)}")
251 try:
252 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
253 handle.write(payload)
254 handle.write("\n")
255 handle.flush()
256 os.fsync(handle.fileno())
257 os.replace(temporary, path)
258 finally:
259 if temporary.exists():
260 try:
261 temporary.unlink()
262 except OSError:
263 pass
264
265
267 """Read-and-mark access to one ``lanes/hub/`` directory.
268
269 Lane separation is physical: this store is handed the lane directory and
270 has no path arithmetic that could reach ``inbound/`` or ``outbound/``.
271 """
272
274 self,
275 directory: str | Path,
276 *,
277 now: Callable[[], datetime] = utc_now,
278 ) -> None:
279 self.directory = Path(directory)
280 self._now = now
281
282 def paths(self) -> tuple[Path, ...]:
283 """Return event files in arrival (filename) order, ignoring dotfiles."""
284 if not self.directory.is_dir():
285 return ()
286 return tuple(
287 sorted(
288 path
289 for path in self.directory.glob("*.json")
290 if path.is_file() and not path.name.startswith(".")
291 )
292 )
293
294 def _read(self, path: Path) -> HubLaneEvent:
295 try:
296 return HubLaneEvent.model_validate_json(path.read_text(encoding="utf-8"))
297 except (OSError, UnicodeDecodeError, ValueError) as exc:
298 raise HubLaneError(
299 f"could not parse hub lane event at {path} ({type(exc).__name__})"
300 ) from None
301
302 def list_events(self, *, relayed: bool | None = None) -> tuple[HubLaneEvent, ...]:
303 """Return stored events in arrival order, optionally by relay state."""
304 events = tuple(self._read(path) for path in self.paths())
305 if relayed is None:
306 return events
307 return tuple(event for event in events if event.is_relayed is relayed)
308
309 def path_for(self, event_id: str) -> Path:
310 """Return the file holding one event or fail with its public id."""
311 suffix = f"_{event_id}.json"
312 for path in self.paths():
313 if path.name.endswith(suffix):
314 return path
315 raise HubLaneError(f"hub lane event {event_id!r} is not in the lane")
316
317 def get(self, event_id: str) -> HubLaneEvent:
318 """Return one stored event or fail with its public id."""
319 return self._read(self.path_for(event_id))
320
321 def mark_relayed(self, event_id: str, *, chunk_count: int = 1) -> HubLaneEvent:
322 """Record that one message reached the transport."""
323 if chunk_count < 1:
324 raise ValueError("a relayed message must have sent at least one message")
325 path = self.path_for(event_id)
326 event = self._read(path)
327 if event.is_relayed:
328 raise HubLaneError(
329 f"hub lane event {event_id!r} was already relayed at "
330 f"{event.relayed_at_utc}"
331 )
332 updated = event.model_copy(
333 update={
334 "relayed_at_utc": timestamp(self._now()),
335 "relay_chunk_count": chunk_count,
336 }
337 )
339 path, json.dumps(updated.model_dump(mode="json"), indent=2, sort_keys=True)
340 )
341 _dbg("OC_CONNECT.HUB", "mark_relayed",
342 f"event={event_id} kind={event.kind}", 2)
343 return updated
344
345
346@dataclass(frozen=True)
348 """What the relay did with one message, in terms a CLI can print."""
349
350 message_id: str
351 kind: str
352 sender: str
353 subject: str | None
354 choices: tuple[str, ...]
355 audit_id: str
356
357
359 """Forward hub messages addressed to the remote operator, verbatim.
360
361 Every message still crosses the plan-219 pipeline -- grant check, egress
362 scan, durable outbox, audit -- because the relay delegates to
363 :class:`~oc_connect.delivery.OutboundSender` rather than reimplementing it.
364 """
365
367 self,
368 *,
369 store: HubLaneStore,
370 sender: OutboundSender,
371 audit: AuditStore,
372 grantee: str,
373 destination: str,
374 grant_id: str,
375 risk_class: str | None = None,
376 now: Callable[[], datetime] = utc_now,
377 ) -> None:
378 self.store = store
379 self.sender = sender
380 self.audit = audit
381 self.grantee = grantee
382 self.destination = destination
383 self.grant_id = grant_id
384 self.risk_class = risk_class
385 self._now = now
386
388 self,
389 event: HubLaneEvent,
390 outcome: str,
391 *,
392 attempts: int,
393 error_code: str | None = None,
394 ) -> None:
395 from .audit import content_hash
396
397 descriptor = lane(HUB_LANE)
398 self.audit.append(
399 connector="telegram",
400 grant_id=self.grant_id,
401 destination=self.destination,
402 outcome=outcome, # type: ignore[arg-type]
403 digest=content_hash(event.body),
404 idempotency_key=event.event_id,
405 attempts=attempts,
406 error_code=error_code,
407 lane=HUB_LANE,
408 direction=descriptor.direction,
409 risk_class=descriptor.risk_ceiling,
410 )
411
412 def pending(self) -> tuple[HubLaneEvent, ...]:
413 """Unrelayed messages addressed to the remote operator, oldest first."""
414 return tuple(
415 event
416 for event in self.store.list_events(relayed=False)
417 if event.is_for_remote_operator
418 )
419
420 def relay_one(self, event: HubLaneEvent) -> HubLaneOutcome:
421 """Relay one message, or leave it unrelayed and raise.
422
423 The egress scan runs on the WHOLE rendered text before anything is
424 queued. One message per hub message, keyed by its id: see the module
425 docstring for why this lane does not chunk.
426 """
427 func = "relay_one"
428 text = render_event(event)
429 try:
430 require_safe(text)
431 except EgressBlockedError:
432 self._audit(event, "blocked", attempts=0, error_code="egress_finding")
433 _dbg("OC_CONNECT.HUB", func,
434 # OCT-LINT: disable=no_hardcoded_secrets reason="Diagnostic prose naming the S6 class SECURITY_ERROR on the egress-refusal path; scores 4.59 bits/char and carries no credential, the one interpolated value being a ULID. Operator-ruled 2026-09-13 (plan 259 escalation, line-scoped)"
435 f"SECURITY_ERROR: blocked hub message event={event.event_id}",
436 1, override=True)
437 raise
438 choices = HUB_REPLY_CHOICES if event.is_human_ask else None
439 receipt = self.sender.send(
440 grantee=self.grantee,
441 destination=self.destination,
442 text=text,
443 grant_id=self.grant_id,
444 risk_class=self.risk_class,
445 # The message id UNCHANGED: this is what a reply correlates to.
446 idempotency_key=event.event_id,
447 choices=choices,
448 )
449 if receipt.outcome != "sent":
450 self._audit(event, "failed", attempts=1, error_code=receipt.outcome)
451 raise HubLaneRelayError(
452 f"hub message {event.event_id!r} was not delivered (outcome "
453 f"{receipt.outcome!r}); the message stays unrelayed and "
454 "re-running resumes from the durable outbox"
455 )
456 self.store.mark_relayed(event.event_id)
457 self._audit(event, "sent", attempts=1)
458 _dbg("OC_CONNECT.HUB", func,
459 f"relayed event={event.event_id} kind={event.kind} "
460 f"choices={len(choices) if choices else 0}", 2)
461 return HubLaneOutcome(
462 message_id=event.message_id,
463 kind=event.kind,
464 sender=event.sender,
465 subject=event.subject,
466 choices=tuple(choices or ()),
467 audit_id=receipt.audit_id,
468 )
469
470 def relay_pending(self) -> tuple[HubLaneOutcome, ...]:
471 """Relay every pending message, oldest first, stopping on the first refusal."""
472 return tuple(self.relay_one(event) for event in self.pending())
473
474
475@dataclass(frozen=True)
477 """One inbound reply, resolved back to the hub message it answers."""
478
479 #: The hub message this reply answers -- for an ask, the ask's id.
480 hub_message_id: str
481 #: The reply-vocabulary token the operator sent or tapped.
482 answer: str
483 #: The three fields a ``telegram``-channel provenance must carry.
484 correlation: Mapping[str, str]
485
486
488 inbound_message_id: str,
489 *,
490 inbox: InboxStore,
491 audit: AuditStore,
492) -> HubReply:
493 """Resolve one stored inbound message back to the hub message it answers.
494
495 The walk is the poller's own, read backwards: the stored message carries
496 the audit id of the send it replied to; that record carries the
497 idempotency key; and for this lane the key IS the hub message id. Every
498 missing link, and every link that lands on a different lane, is refused by
499 name rather than guessed -- turning an uncorrelated message into a ruling
500 is precisely the failure the hub's provenance rule exists to prevent.
501
502 Returns the three correlation fields the hub's ``telegram`` channel
503 requires. Building the ruling from them is ``oc_chat``'s job: a ruling is
504 a hub message, and this package does not know what one is.
505
506 Raises:
507 UncorrelatedReplyError: VALIDATION_ERROR (L3) -- the message is not
508 stored, carries no correlation, names an audit record that does
509 not exist, or answers a send on another lane.
510 """
511 func = "resolve_hub_reply"
512 try:
513 message = inbox.get(inbound_message_id)
514 except (KeyError, ValueError) as exc:
516 f"inbound message {inbound_message_id!r} is not in the inbox"
517 ) from exc
518
519 audit_id = message.correlation.audit_id
520 if not audit_id:
522 f"inbound message {inbound_message_id!r} carries no correlation; a "
523 "freely typed message answers no question and is not made into one"
524 )
525
526 named = next(
527 (record for record in audit.read_all() if record.audit_id == audit_id),
528 None,
529 )
530 if named is None:
532 f"inbound message {inbound_message_id!r} names audit record "
533 f"{audit_id!r}, which the audit trail does not hold"
534 )
535 if named.lane != HUB_LANE:
537 f"inbound message {inbound_message_id!r} answers a send on lane "
538 f"{named.lane!r}, not {HUB_LANE!r}; a reply to a worker report is "
539 "not a ruling on a hub ask"
540 )
541
542 answer = message.vocabulary_token or message.text.strip()
543 reply = HubReply(
544 hub_message_id=named.idempotency_key,
545 answer=answer,
546 correlation={
547 "message_id": message.message_id,
548 "audit_id": audit_id,
549 "content_hash": message.content_hash,
550 },
551 )
552 _dbg("OC_CONNECT.HUB", func,
553 f"message={inbound_message_id} answers hub={reply.hub_message_id} "
554 f"with {answer}", 2)
555 return reply
556
557
558__all__ = [
559 "HUB_LANE",
560 "HUB_LANE_SCHEMA_VERSION",
561 "HUB_LANE_SOURCE",
562 "HUB_MESSAGE_BEGIN",
563 "HUB_MESSAGE_END",
564 "HUB_REPLY_CHOICES",
565 "NEUTRALISED_MARKER",
566 "REMOTE_OPERATOR",
567 "HubKind",
568 "HubLaneError",
569 "HubLaneEvent",
570 "HubLaneOutcome",
571 "HubLaneRelay",
572 "HubLaneRelayError",
573 "HubLaneStore",
574 "HubReply",
575 "UncorrelatedReplyError",
576 "hub_lane_dir",
577 "render_event",
578 "resolve_hub_reply",
579]
str _required_text_is_nonblank(cls, str value)
Definition hub_lane.py:184
str _message_id_is_the_event_id(cls, str value, Any info)
Definition hub_lane.py:191
None __init__(self, *, HubLaneStore store, OutboundSender sender, AuditStore audit, str grantee, str destination, str grant_id, str|None risk_class=None, Callable[[], datetime] now=utc_now)
Definition hub_lane.py:377
None _audit(self, HubLaneEvent event, str outcome, *, int attempts, str|None error_code=None)
Definition hub_lane.py:394
tuple[HubLaneOutcome,...] relay_pending(self)
Definition hub_lane.py:470
tuple[HubLaneEvent,...] pending(self)
Definition hub_lane.py:412
HubLaneOutcome relay_one(self, HubLaneEvent event)
Definition hub_lane.py:420
HubLaneEvent mark_relayed(self, str event_id, *, int chunk_count=1)
Definition hub_lane.py:321
None __init__(self, str|Path directory, *, Callable[[], datetime] now=utc_now)
Definition hub_lane.py:278
tuple[Path,...] paths(self)
Definition hub_lane.py:282
HubLaneEvent get(self, str event_id)
Definition hub_lane.py:317
tuple[HubLaneEvent,...] list_events(self, *, bool|None relayed=None)
Definition hub_lane.py:302
Path path_for(self, str event_id)
Definition hub_lane.py:309
HubLaneEvent _read(self, Path path)
Definition hub_lane.py:294
HubReply resolve_hub_reply(str inbound_message_id, *, InboxStore inbox, AuditStore audit)
Definition hub_lane.py:492
str render_event(HubLaneEvent event)
Definition hub_lane.py:221
Path hub_lane_dir(CommsBus bus)
Definition hub_lane.py:140
None _atomic_write(Path path, str payload)
Definition hub_lane.py:248