1"""The hub lane: addressed messages at rest, their relay, and the reply path.
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
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`::
17 "schema_version": "1.0",
18 "event_id": "<26-character ULID; IS the message_id>",
20 "direction": "outbound",
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,
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,
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}
40File naming follows the §3 bus convention, ``<utc-stamp>_hub_<ulid>.json``.
42Three differences from the two lanes it mirrors are deliberate.
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
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.
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.
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)
79from __future__
import annotations
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
90from oc_diagnostics
import _dbg
91from option_c_connector_contracts
import lane
92from pydantic
import BaseModel, ConfigDict, field_validator
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
104HUB_LANE_SOURCE =
"hub"
106HUB_LANE_SCHEMA_VERSION =
"1.0"
110HUB_REPLY_CHOICES: tuple[str, ...] = (
"YES",
"NO",
"ACK",
"HOLD")
114REMOTE_OPERATOR =
"operator@remote"
116HubKind = Literal[
"dispatch",
"report",
"alert",
"ask",
"ruling",
"instruction"]
120HUB_MESSAGE_BEGIN =
"<<<BEGIN HUB MESSAGE {event_id}>>>"
121HUB_MESSAGE_END =
"<<<END HUB MESSAGE {event_id}>>>"
125NEUTRALISED_MARKER =
"<< <"
129 """A hub lane event is unreadable, malformed, or already consumed."""
133 """The relay could not put one message on the transport."""
137 """An inbound message does not resolve back to a hub message."""
141 """Return the hub lane directory for *bus*.
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()``.
149 return bus.lanes_dir / HUB_LANE
153 """One hub message at rest in ``lanes/hub/``."""
155 model_config = ConfigDict(frozen=
True, extra=
"forbid", strict=
True)
160 direction: Literal[
"outbound"]
161 risk_class: Literal[
"low"]
164 relayed_at_utc: str |
None =
None
165 relay_chunk_count: int |
None =
None
172 subject: str |
None =
None
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]
180 "schema_version",
"event_id",
"created_at_utc",
"message_id",
"at",
181 "sender",
"recipient",
185 if not value.strip():
186 raise ValueError(
"must not be blank")
189 @field_validator("message_id")
192 """The one identity, asserted at the boundary.
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.
198 event_id = info.data.get(
"event_id")
199 if event_id
is not None and value != event_id:
201 f
"message_id {value!r} must equal event_id {event_id!r}"
207 """True once this message reached the transport."""
212 """True when this is a question only the operator may answer."""
217 """True when this message is addressed away from the control centre."""
222 """Return the text an operator reads for one hub message, fenced.
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.
230 state =
"relayed" if event.is_relayed
else "unrelayed"
232 f
"[{state}] {event.event_id} {event.created_at_utc} "
233 f
"lane={event.lane} kind={event.kind} "
234 f
"{event.sender} -> {event.recipient}"
237 header += f
" subject={event.subject}"
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}"
249 path.parent.mkdir(parents=
True, exist_ok=
True)
250 temporary = path.with_name(f
".tmp-{path.name}-{_secrets.token_hex(8)}")
252 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
253 handle.write(payload)
256 os.fsync(handle.fileno())
257 os.replace(temporary, path)
259 if temporary.exists():
267 """Read-and-mark access to one ``lanes/hub/`` directory.
269 Lane separation is physical: this store is handed the lane directory and
270 has no path arithmetic that could reach ``inbound/`` or ``outbound/``.
275 directory: str | Path,
277 now: Callable[[], datetime] = utc_now,
282 def paths(self) -> tuple[Path, ...]:
283 """Return event files in arrival (filename) order, ignoring dotfiles."""
289 for path
in self.
directory.glob(
"*.json")
290 if path.is_file()
and not path.name.startswith(
".")
294 def _read(self, path: Path) -> HubLaneEvent:
296 return HubLaneEvent.model_validate_json(path.read_text(encoding=
"utf-8"))
297 except (OSError, UnicodeDecodeError, ValueError)
as exc:
299 f
"could not parse hub lane event at {path} ({type(exc).__name__})"
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())
307 return tuple(event
for event
in events
if event.is_relayed
is relayed)
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):
315 raise HubLaneError(f
"hub lane event {event_id!r} is not in the lane")
317 def get(self, event_id: str) -> HubLaneEvent:
318 """Return one stored event or fail with its public id."""
321 def mark_relayed(self, event_id: str, *, chunk_count: int = 1) -> HubLaneEvent:
322 """Record that one message reached the transport."""
324 raise ValueError(
"a relayed message must have sent at least one message")
326 event = self.
_read(path)
329 f
"hub lane event {event_id!r} was already relayed at "
330 f
"{event.relayed_at_utc}"
332 updated = event.model_copy(
334 "relayed_at_utc": timestamp(self.
_now()),
335 "relay_chunk_count": chunk_count,
339 path, json.dumps(updated.model_dump(mode=
"json"), indent=2, sort_keys=
True)
341 _dbg(
"OC_CONNECT.HUB",
"mark_relayed",
342 f
"event={event_id} kind={event.kind}", 2)
346@dataclass(frozen=True)
348 """What the relay did with one message, in terms a CLI can print."""
354 choices: tuple[str, ...]
359 """Forward hub messages addressed to the remote operator, verbatim.
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.
370 sender: OutboundSender,
375 risk_class: str |
None =
None,
376 now: Callable[[], datetime] = utc_now,
393 error_code: str |
None =
None,
395 from .audit
import content_hash
397 descriptor = lane(HUB_LANE)
399 connector=
"telegram",
403 digest=content_hash(event.body),
404 idempotency_key=event.event_id,
406 error_code=error_code,
408 direction=descriptor.direction,
409 risk_class=descriptor.risk_ceiling,
412 def pending(self) -> tuple[HubLaneEvent, ...]:
413 """Unrelayed messages addressed to the remote operator, oldest first."""
416 for event
in self.
store.list_events(relayed=
False)
417 if event.is_for_remote_operator
420 def relay_one(self, event: HubLaneEvent) -> HubLaneOutcome:
421 """Relay one message, or leave it unrelayed and raise.
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.
431 except EgressBlockedError:
432 self.
_audit(event,
"blocked", attempts=0, error_code=
"egress_finding")
433 _dbg(
"OC_CONNECT.HUB", func,
435 f
"SECURITY_ERROR: blocked hub message event={event.event_id}",
438 choices = HUB_REPLY_CHOICES
if event.is_human_ask
else None
439 receipt = self.
sender.send(
446 idempotency_key=event.event_id,
449 if receipt.outcome !=
"sent":
450 self.
_audit(event,
"failed", attempts=1, error_code=receipt.outcome)
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"
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)
462 message_id=event.message_id,
465 subject=event.subject,
466 choices=tuple(choices
or ()),
467 audit_id=receipt.audit_id,
471 """Relay every pending message, oldest first, stopping on the first refusal."""
475@dataclass(frozen=True)
477 """One inbound reply, resolved back to the hub message it answers."""
484 correlation: Mapping[str, str]
488 inbound_message_id: str,
493 """Resolve one stored inbound message back to the hub message it answers.
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.
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.
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.
511 func =
"resolve_hub_reply"
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"
519 audit_id = message.correlation.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"
527 (record
for record
in audit.read_all()
if record.audit_id == audit_id),
532 f
"inbound message {inbound_message_id!r} names audit record "
533 f
"{audit_id!r}, which the audit trail does not hold"
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"
542 answer = message.vocabulary_token
or message.text.strip()
544 hub_message_id=named.idempotency_key,
547 "message_id": message.message_id,
548 "audit_id": audit_id,
549 "content_hash": message.content_hash,
552 _dbg(
"OC_CONNECT.HUB", func,
553 f
"message={inbound_message_id} answers hub={reply.hub_message_id} "
560 "HUB_LANE_SCHEMA_VERSION",
565 "NEUTRALISED_MARKER",
575 "UncorrelatedReplyError",
str _required_text_is_nonblank(cls, str value)
str _message_id_is_the_event_id(cls, str value, Any info)
bool is_for_remote_operator(self)
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)
None _audit(self, HubLaneEvent event, str outcome, *, int attempts, str|None error_code=None)
tuple[HubLaneOutcome,...] relay_pending(self)
tuple[HubLaneEvent,...] pending(self)
HubLaneOutcome relay_one(self, HubLaneEvent event)
HubLaneEvent mark_relayed(self, str event_id, *, int chunk_count=1)
None __init__(self, str|Path directory, *, Callable[[], datetime] now=utc_now)
tuple[Path,...] paths(self)
HubLaneEvent get(self, str event_id)
tuple[HubLaneEvent,...] list_events(self, *, bool|None relayed=None)
Path path_for(self, str event_id)
HubLaneEvent _read(self, Path path)
HubReply resolve_hub_reply(str inbound_message_id, *, InboxStore inbox, AuditStore audit)
str render_event(HubLaneEvent event)
Path hub_lane_dir(CommsBus bus)
None _atomic_write(Path path, str payload)