oc_orchestrator
Loading...
Searching...
No Matches
hub.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/hub.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_orchestrator/tests/test_259_hub.py"
6"""
7The hub's contract: one address grammar, six kinds, one human rule.
8
9Purpose
10-------
11Plan 259 P2, from the factory-decomposition design note §4. The hub is a
12MESSAGE CONTRACT, not a transport. Two pipes and a reply path already
13exist -- the worker-report lane, the session-alert lane, and the
14connector's correlated inbound reply -- and what turns them into a hub is
15three additions, all declared here:
16
17**Addresses**, so a message is ROUTED rather than deposited. Four forms
18and no others: ``orchestrator``, ``operator@local`` (the control centre,
19which today is the CLI in the operator's own session), ``operator@remote``
20(the connector), and ``agent-NNN`` for each worker instance.
21
22**Kinds with replies.** ``report`` and ``alert`` flow one way. An ``ask``
23expects an answer and a ``ruling`` IS that answer, naming the ask it
24answers, so a question any human channel can answer is a first-class
25thing. ``dispatch`` and ``instruction`` are records of what was done and
26what was told, so the orchestrator's picture of the fleet is the log and
27not a guess.
28
29**Provenance on every message, and a rule about rulings.** A ruling
30carries who answered and through which channel. This is not a nicety: it
31derives from an operator ruling already in force, that a security
32approval must be in the operator's own voice and never relayed by an
33agent. In a hub where the orchestrator agent, the control centre and
34Telegram can all answer, an unsigned reply makes an agent-relayed yes
35indistinguishable from the human's. :func:`check_ruling_provenance` is
36the ONE place that rule lives.
37
38Responsibilities
39----------------
40- :func:`parse_address` -- the whole grammar, refusing by name. Agent
41 ids are validated by
42 :func:`oc_orchestrator.agent_instances.validate_agent_id`, IMPORTED
43 rather than reimplemented, so there is one definition of ``agent-NNN``
44 in the tree.
45- :data:`SUPPORTED_KINDS` -- the six, checked in one place.
46- :class:`Provenance` / :class:`HubMessage` -- frozen value types;
47 :func:`new_message` is the one construction path.
48- :func:`check_ruling_provenance` -- the human rule.
49- :class:`HubLog` / :class:`Carrier` -- the two Protocols the backends
50 and the carriers satisfy.
51- :class:`Hub` -- ``send`` (record, then route) and ``rule`` (build the
52 ruling from the ask and send it).
53- :class:`HubError` and its named subclasses -- every refusal.
54
55Diagnostics
56-----------
57Domain: OC_CHAT.MAIN
58Levels:
59 L2 - lifecycle events (a message sent, a carrier chosen)
60 L3 - semantic details (a refusal, with what it refused)
61 L4 - deep tracing (module load)
62
63Contracts
64---------
65- **The log is the truth.** :meth:`Hub.send` RECORDS BEFORE IT DELIVERS,
66 and a carrier failure after the record is a :class:`CarrierError` with
67 the entry left standing -- the file lane redelivers, and a message
68 that reached no carrier is still a message that was sent. That is
69 design-note invariant 1 made mechanical: direct does not mean unlogged.
70- **A ruling on a human-required ask is refused from any agent actor.**
71 Not discouraged, not flagged: refused, by construction, in
72 :func:`check_ruling_provenance`, which :meth:`Hub.send` calls before
73 it records. A refused ruling is never recorded and never delivered.
74- ``answer`` is one of :data:`RULING_ANSWERS` -- the connector's reply
75 vocabulary -- so a Telegram tap, a CLI word and a control-centre
76 button all produce the same four values.
77- :class:`Provenance.correlation` is an IMMUTABLE mapping. A correlation
78 a caller can edit after the check is not evidence.
79- ``message_id`` is a ULID: :func:`new_ulid` and the Crockford alphabet
80 are lifted VERBATIM from ``plan_status.py:450-464`` and ``:132``,
81 waiver text included, because the lane readers compare these bytes and
82 a second algorithm here would be a second answer. The two now sit in
83 one package and could in principle be deduplicated; plan 272 P8 leaves
84 that to the follow-up that also settles the comms-root rule, because a
85 shared ULID module is a decision about the package's shape rather than
86 a consequence of the move.
87- This module imports nothing but the standard library, the diagnostics
88 runtime and the one agent-id validator beside it -- no registry, no
89 consumer, no toolchain. A witness measures that with the committed
90 extractor, and ``tests/test_t1_leaf_guard.py`` measures it statically.
91- Nothing here reads the process working directory, opens a file, or
92 starts a subprocess. Routing is a decision; moving bytes is a carrier's
93 job.
94
95Dependencies
96------------
97- oc_diagnostics (structured debug logging via _dbg)
98- oc_orchestrator.agent_instances (the ONE definition of ``agent-NNN``)
99"""
100
101from __future__ import annotations
102
103import secrets
104from collections.abc import Mapping
105from dataclasses import dataclass, field
106from datetime import datetime, timezone
107from types import MappingProxyType
108from typing import Any, Callable, Protocol, runtime_checkable
109
110from oc_diagnostics import _dbg
111
112from oc_orchestrator.agent_instances import validate_agent_id
113
114_dbg("OC_CHAT.MAIN", "module", "hub loaded", 4)
115
116
117# ── Addresses ────────────────────────────────────────────────────────────
118
119
120#: The orchestrator agent.
121ADDRESS_ORCHESTRATOR = "orchestrator"
122
123#: The operator at the control centre. Today that is the CLI in the
124#: operator's own session; when a web control centre exists it is that,
125#: and nothing in this module changes.
126ADDRESS_OPERATOR_LOCAL = "operator@local"
127
128#: The operator, reachable only through the connector.
129ADDRESS_OPERATOR_REMOTE = "operator@remote"
130
131#: The three fixed addresses. A worker is ``agent-NNN`` and is validated
132#: by the instance module rather than by a fourth literal.
133FIXED_ADDRESSES: tuple[str, ...] = (
134 ADDRESS_ORCHESTRATOR,
135 ADDRESS_OPERATOR_LOCAL,
136 ADDRESS_OPERATOR_REMOTE,
137)
138
139
140# ── Kinds ────────────────────────────────────────────────────────────────
141
142
143KIND_DISPATCH = "dispatch"
144KIND_REPORT = "report"
145KIND_ALERT = "alert"
146KIND_ASK = "ask"
147KIND_RULING = "ruling"
148KIND_INSTRUCTION = "instruction"
149
150#: The six kinds, closed. Read at call time, so a seventh kind would be
151#: one edit here plus whatever that kind's own rule needs.
152SUPPORTED_KINDS: frozenset[str] = frozenset(
153 {KIND_DISPATCH, KIND_REPORT, KIND_ALERT, KIND_ASK, KIND_RULING, KIND_INSTRUCTION}
154)
155
156#: What an ask may require of its answerer.
157REQUIRES_HUMAN = "human"
158REQUIRES_ORCHESTRATOR = "orchestrator"
159SUPPORTED_REQUIRES: tuple[str, ...] = (REQUIRES_HUMAN, REQUIRES_ORCHESTRATOR)
160
161#: The connector's reply vocabulary, restated so a ruling built in any
162#: channel carries a value the Telegram keyboard could also have produced.
163#: Mirrored rather than imported: this module is a dispatch-layer leaf and
164#: must not pull the connector contracts into that closure.
165RULING_ANSWERS: tuple[str, ...] = ("YES", "NO", "ACK", "HOLD")
166
167#: The registry's three actor types, checked by name for the same reason:
168#: the registry log is one backend, and a claim written in the file log
169#: must use the registry's own vocabulary or the two cannot be compared.
170ACTOR_TYPES: tuple[str, ...] = (
171 "human_operator",
172 "orchestrator_agent",
173 "worker_agent",
174)
175
176#: How a message reached the hub. ``session`` is where an AGENT speaks;
177#: ``cli`` and ``lane`` and ``telegram`` all leave a trace off-process.
178CHANNELS: tuple[str, ...] = ("session", "cli", "lane", "telegram")
179
180#: The channels a HUMAN answer may arrive on. A human at the control
181#: centre types into a terminal (``cli``) or taps on their phone
182#: (``telegram``); ``session`` is the agent's own channel and ``lane`` is
183#: a file an agent wrote, so neither can carry a human's voice.
184HUMAN_CHANNELS: tuple[str, ...] = ("cli", "telegram")
185
186#: The actor type a human-required ruling must carry.
187HUMAN_ACTOR_TYPE = "human_operator"
188
189
190# ── ULID ─────────────────────────────────────────────────────────────────
191
192
193#: Crockford base32, the ULID alphabet: no I, L, O or U. Lifted VERBATIM
194#: from ``scripts/plan_status.py:126-127`` including its ruled waiver,
195#: because the lane readers compare these bytes and a derivation here
196#: would be a second answer to a settled question.
197# 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), same constant as oc_connect.comms and scripts/plan_status.py"
198CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
199
200
201def _encode(value: int, length: int) -> str:
202 digits = []
203 for _ in range(length):
204 value, remainder = divmod(value, 32)
205 digits.append(CROCKFORD_ALPHABET[remainder])
206 return "".join(reversed(digits))
207
208
209def new_ulid(now: datetime, *, entropy: bytes | None = None) -> str:
210 """Return a 26-character ULID: 48 bits of time, 80 bits of randomness.
211
212 Lifted VERBATIM from ``scripts/plan_status.py:445-460``, which says
213 the same of ``oc_connect.comms.new_ulid``. Time-first encoding makes
214 lexical order arrival order, which is what lets a reader walk the hub
215 lane with a plain directory sort, exactly as the other two lanes are
216 walked.
217 """
218 if now.tzinfo is None:
219 now = now.replace(tzinfo=timezone.utc)
220 milliseconds = int(now.astimezone(timezone.utc).timestamp() * 1000)
221 raw = entropy if entropy is not None else secrets.token_bytes(10)
222 if len(raw) != 10:
223 raise ValueError("ULID entropy must be exactly ten bytes")
224 return _encode(milliseconds, 10) + _encode(int.from_bytes(raw, "big"), 16)
225
226
227def _now_iso() -> str:
228 """A local-with-offset ISO 8601 stamp, seconds resolution.
229
230 The project-wide convention, matching ``work_queue_file._now_iso``
231 and ``plan_inventory._now_iso``. An offset is not decoration: a
232 stamp without one is not a point in time.
233 """
234 return datetime.now().astimezone().replace(microsecond=0).isoformat()
235
236
237# ── Refusals ─────────────────────────────────────────────────────────────
238
239
240class HubError(Exception):
241 """Base class for every refusal the hub makes."""
242
243
244class AddressError(HubError):
245 """A string is not one of the four address forms."""
246
247
249 """A message names a kind the hub does not carry, or breaks that kind's shape."""
250
251
253 """A message's provenance does not support what the message claims."""
254
255
257 """No carrier would take the message, or the one that did failed.
258
259 Raised AFTER the entry is recorded, always. The log is the truth and
260 a message nobody carried is still a message that was sent.
261 """
262
263
265 """A ruling names something that is not an open ask."""
266
267
268# ── The grammar ──────────────────────────────────────────────────────────
269
270
271def parse_address(value: str) -> str:
272 """Return *value* if it is an address, else refuse it by name.
273
274 The whole grammar, in one function. ``agent-NNN`` is delegated to
275 :func:`oc_orchestrator.agent_instances.validate_agent_id` -- imported,
276 never reimplemented -- so the tree has exactly one definition of what
277 an instance identity looks like and a change there is a change here.
278
279 Raises:
280 AddressError: VALIDATION_ERROR (L3) -- the message names the
281 value it refused and every form it accepts.
282 """
283 func = "parse_address"
284 if isinstance(value, str) and value in FIXED_ADDRESSES:
285 return value
286 if isinstance(value, str) and value.startswith("agent-"):
287 try:
288 return validate_agent_id(value)
289 except ValueError:
290 pass
291 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: address {value!r}", 3)
292 raise AddressError(
293 f"{value!r} is not a hub address; expected one of "
294 f"{', '.join(FIXED_ADDRESSES)}, or an agent-NNN instance identity"
295 )
296
297
298def check_kind(kind: str) -> None:
299 """Refuse *kind* unless it is in :data:`SUPPORTED_KINDS`, by name."""
300 func = "check_kind"
301 if kind not in SUPPORTED_KINDS:
302 accepted = ", ".join(sorted(SUPPORTED_KINDS))
303 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: kind {kind!r}", 3)
304 raise KindError(f"unknown hub kind {kind!r}; the hub carries: {accepted}")
305
306
307# ── Value types ──────────────────────────────────────────────────────────
308
309
310@dataclass(frozen=True)
312 """Who is speaking, through which channel, and what act proves it.
313
314 ``correlation`` is what makes the claim checkable, and what it holds
315 depends on the channel: for ``telegram`` the inbound message id, the
316 audit id and the content hash, which together name one tap the
317 connector recorded; for ``lane`` the source lane and event id; for
318 ``cli`` the process user and working directory; empty for
319 ``session``, because an agent speaking in its own session has no
320 off-process act to point at -- which is exactly why the human rule
321 refuses that channel.
322
323 It is stored as an immutable mapping: a correlation a caller can edit
324 after :func:`check_ruling_provenance` has passed is not evidence.
325 """
326
327 #: An address -- who the claim is made on behalf of.
328 actor: str
329 #: One of :data:`ACTOR_TYPES`, the registry's own three.
330 actor_type: str
331 #: One of :data:`CHANNELS`.
332 channel: str
333 #: Channel-specific identifiers; immutable once constructed.
334 correlation: Mapping[str, str] = field(default_factory=dict)
335
336 def __post_init__(self) -> None:
337 """Validate the three vocabularies and freeze the correlation."""
338 func = "Provenance.__post_init__"
340 if self.actor_type not in ACTOR_TYPES:
341 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: actor_type {self.actor_type!r}", 3)
342 raise ProvenanceError(
343 f"actor_type {self.actor_type!r} is not one of the registry's "
344 f"three: {', '.join(ACTOR_TYPES)}"
345 )
346 if self.channel not in CHANNELS:
347 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: channel {self.channel!r}", 3)
348 raise ProvenanceError(
349 f"channel {self.channel!r} is not declared; the hub knows: "
350 f"{', '.join(CHANNELS)}"
351 )
352 frozen = MappingProxyType(
353 {str(key): str(value) for key, value in dict(self.correlation).items()}
354 )
355 object.__setattr__(self, "correlation", frozen)
356
357 def as_dict(self) -> dict[str, object]:
358 """This provenance as plain JSON-safe data, for a log line or a row."""
359 return {
360 "actor": self.actor,
361 "actor_type": self.actor_type,
362 "channel": self.channel,
363 "correlation": dict(self.correlation),
364 }
365
366 @classmethod
367 def from_mapping(cls, raw: Mapping[str, object]) -> "Provenance":
368 """Rebuild a provenance from what :meth:`as_dict` wrote."""
369 correlation = raw.get("correlation") or {}
370 if not isinstance(correlation, Mapping):
371 raise ProvenanceError("provenance correlation must be a mapping")
372 return cls(
373 actor=str(raw.get("actor", "")),
374 actor_type=str(raw.get("actor_type", "")),
375 channel=str(raw.get("channel", "")),
376 correlation={str(k): str(v) for k, v in correlation.items()},
377 )
378
379
380@dataclass(frozen=True)
382 """One addressed message. Construct through :func:`new_message`."""
383
384 #: A 26-character ULID; lexical order is arrival order.
385 message_id: str
386 #: ISO 8601 WITH an explicit offset.
387 at: str
388 #: One of :data:`SUPPORTED_KINDS`.
389 kind: str
390 #: An address.
391 sender: str
392 #: An address.
393 recipient: str
394 #: The work item this is about -- a plan id such as ``258-work-queue``
395 #: -- or ``None`` when the message is about nothing in particular.
396 subject: str | None
397 #: The text. The hub never interprets it.
398 body: str
399 #: The ask a ruling answers; ``None`` for every other kind.
400 in_reply_to: str | None = None
401 #: For an ask, one of :data:`SUPPORTED_REQUIRES`; ``None`` otherwise.
402 requires: str | None = None
403 #: For a ruling, one of :data:`RULING_ANSWERS`; ``None`` otherwise.
404 answer: str | None = None
405 #: Who is speaking and what proves it.
406 provenance: Provenance = field(
407 default_factory=lambda: Provenance(
408 actor=ADDRESS_ORCHESTRATOR, actor_type="orchestrator_agent",
409 channel="session",
410 )
411 )
412
413 @property
414 def summary(self) -> str:
415 """The first non-blank line of the body -- what a lane envelope carries."""
416 for line in self.body.splitlines():
417 if line.strip():
418 return line.strip()
419 return ""
420
421 def as_dict(self) -> dict[str, object]:
422 """This message as plain JSON-safe data."""
423 return {
424 "message_id": self.message_id,
425 "at": self.at,
426 "kind": self.kind,
427 "sender": self.sender,
428 "recipient": self.recipient,
429 "subject": self.subject,
430 "body": self.body,
431 "in_reply_to": self.in_reply_to,
432 "requires": self.requires,
433 "answer": self.answer,
434 "provenance": self.provenance.as_dict(),
435 }
436
437 @classmethod
438 def from_mapping(cls, raw: Mapping[str, object]) -> "HubMessage":
439 """Rebuild a message from what :meth:`as_dict` wrote.
440
441 Deliberately NOT re-validated through :func:`new_message`: this
442 reads back what the hub itself recorded, and a stored message
443 that no longer passes today's rules is a fact about the log, not
444 an input to reject. The readers that care assert their own
445 invariants; this one reconstructs.
446 """
447 return cls(
448 message_id=str(raw["message_id"]),
449 at=str(raw["at"]),
450 kind=str(raw["kind"]),
451 sender=str(raw["sender"]),
452 recipient=str(raw["recipient"]),
453 subject=_optional_str(raw.get("subject")),
454 body=str(raw.get("body", "")),
455 in_reply_to=_optional_str(raw.get("in_reply_to")),
456 requires=_optional_str(raw.get("requires")),
457 answer=_optional_str(raw.get("answer")),
458 provenance=Provenance.from_mapping(
459 raw.get("provenance") or {} # type: ignore[arg-type]
460 ),
461 )
462
463
464def _optional_str(value: object) -> str | None:
465 return None if value is None else str(value)
466
467
468@dataclass(frozen=True)
470 """One recorded message: its position in the log, and the message."""
471
472 #: 1-based, monotonic within one backend's log.
473 seq: int
474 message: HubMessage
475
476
477@dataclass(frozen=True)
479 """What a carrier reports when it moved one message."""
480
481 carrier: str
482 message_id: str
483 #: Whatever the carrier wants the caller to see -- a path, a send id.
484 detail: str | None = None
485
486
487# ── Construction ─────────────────────────────────────────────────────────
488
489
491 *,
492 kind: str,
493 sender: str,
494 recipient: str,
495 body: str,
496 provenance: Provenance,
497 subject: str | None = None,
498 in_reply_to: str | None = None,
499 requires: str | None = None,
500 answer: str | None = None,
501 now: datetime | None = None,
502 entropy: bytes | None = None,
503) -> HubMessage:
504 """Build one validated :class:`HubMessage`, or refuse it by name.
505
506 The ONE construction path, so every shape rule is checked in one
507 place: the kind is in the closed set, the two addresses parse, and
508 the three kind-conditional fields agree with the kind. A ruling must
509 name the ask it answers and carry an answer from the reply
510 vocabulary; only an ask may declare what it requires.
511
512 Raises:
513 AddressError: VALIDATION_ERROR (L3) -- a party is not an address.
514 KindError: VALIDATION_ERROR (L3) -- the kind is unknown, or a
515 kind-conditional field is present where it may not be, or
516 absent where it must be.
517 ProvenanceError: VALIDATION_ERROR (L3) -- from
518 :class:`Provenance`'s own validation.
519 """
520 func = "new_message"
521 check_kind(kind)
522 parse_address(sender)
523 parse_address(recipient)
524
525 if kind == KIND_RULING:
526 if not in_reply_to:
527 raise KindError(
528 "a ruling must name the ask it answers in in_reply_to; a "
529 "ruling that answers nothing is a claim, not an answer"
530 )
531 if answer not in RULING_ANSWERS:
532 raise KindError(
533 f"a ruling's answer must be one of {', '.join(RULING_ANSWERS)}; "
534 f"got {answer!r}. That vocabulary is the connector's, so any "
535 "human channel can produce it."
536 )
537 else:
538 if in_reply_to is not None:
539 raise KindError(f"only a ruling may set in_reply_to; {kind!r} may not")
540 if answer is not None:
541 raise KindError(f"only a ruling may carry an answer; {kind!r} may not")
542
543 if kind == KIND_ASK:
544 if requires not in SUPPORTED_REQUIRES:
545 raise KindError(
546 f"an ask must require one of {', '.join(SUPPORTED_REQUIRES)}; "
547 f"got {requires!r}"
548 )
549 elif requires is not None:
550 raise KindError(f"only an ask may set requires; {kind!r} may not")
551
552 message = HubMessage(
553 message_id=new_ulid(now or datetime.now(timezone.utc), entropy=entropy),
554 at=_now_iso(),
555 kind=kind,
556 sender=sender,
557 recipient=recipient,
558 subject=subject,
559 body=body,
560 in_reply_to=in_reply_to,
561 requires=requires,
562 answer=answer,
563 provenance=provenance,
564 )
565 _dbg("OC_CHAT.MAIN", func, f"{kind} {sender} -> {recipient} ({message.message_id})", 3)
566 return message
567
568
569# ── The human rule ───────────────────────────────────────────────────────
570
571
572def check_ruling_provenance(ask: HubMessage, ruling: HubMessage) -> None:
573 """Refuse *ruling* unless its provenance supports answering *ask*.
574
575 THE one place the human rule lives. An ask that requires a human is
576 answerable only by a ``human_operator`` actor, on a channel a human
577 can actually speak through (:data:`HUMAN_CHANNELS`), with a non-empty
578 correlation naming the act that carried the answer. An
579 ``orchestrator_agent`` or ``worker_agent`` actor can never satisfy
580 it, which is the design note's "an agent-relayed yes is
581 distinguishable from the human's" made mechanical rather than
582 procedural.
583
584 An ask that requires only the orchestrator is answerable by the
585 orchestrator in its own session, because routing an ask to the human
586 and waiting is autonomous while answering a human's ask is not.
587
588 Raises:
589 ProvenanceError: SECURITY_ERROR (L1) -- the message names the
590 actor type, the channel and what was required.
591 """
592 func = "check_ruling_provenance"
593 if ruling.in_reply_to != ask.message_id:
594 raise ProvenanceError(
595 f"ruling {ruling.message_id} has in_reply_to "
596 f"{ruling.in_reply_to!r} and cannot answer ask {ask.message_id}"
597 )
598 if ask.requires != REQUIRES_HUMAN:
599 return
600
601 provenance = ruling.provenance
602 if provenance.actor_type != HUMAN_ACTOR_TYPE:
603 _dbg("OC_CHAT.MAIN", func,
604 f"SECURITY_ERROR: {provenance.actor_type} ruled on a human ask", 1,
605 override=True)
606 raise ProvenanceError(
607 f"ask {ask.message_id} requires a human, and a ruling from actor_type "
608 f"{provenance.actor_type!r} cannot supply one: only "
609 f"{HUMAN_ACTOR_TYPE!r} may. A security approval is in the operator's "
610 "own voice and is never relayed by an agent."
611 )
612 if provenance.channel not in HUMAN_CHANNELS:
613 _dbg("OC_CHAT.MAIN", func,
614 f"SECURITY_ERROR: human ruling on channel {provenance.channel}", 1,
615 override=True)
616 raise ProvenanceError(
617 f"ask {ask.message_id} requires a human, and channel "
618 f"{provenance.channel!r} cannot carry one: a human answers through "
619 f"{' or '.join(HUMAN_CHANNELS)}, each of which leaves a trace "
620 "outside the answering process."
621 )
622 if not provenance.correlation:
623 _dbg("OC_CHAT.MAIN", func, "SECURITY_ERROR: human ruling with no correlation",
624 1, override=True)
625 raise ProvenanceError(
626 f"ask {ask.message_id} requires a human, and the ruling's correlation "
627 "is empty: an answer that names no channel act is not attributable "
628 "to anyone, which is the property the rule exists to keep."
629 )
630 _dbg("OC_CHAT.MAIN", func,
631 f"human ruling on {ask.message_id} accepted via {provenance.channel}", 2)
632
633
634# ── The two Protocols ────────────────────────────────────────────────────
635
636
637@runtime_checkable
638class HubLog(Protocol):
639 """The log, defined once and implemented by both backends."""
640
641 def record(self, message: HubMessage) -> HubEntry:
642 """Append *message* and return the entry it became."""
643
645 self,
646 *,
647 kind: str | None = None,
648 recipient: str | None = None,
649 subject: str | None = None,
650 since_seq: int | None = None,
651 ) -> tuple[HubEntry, ...]:
652 """Recorded entries, oldest first, narrowed by whatever is given."""
653
654 def open_asks(self, *, recipient: str | None = None) -> tuple[HubEntry, ...]:
655 """Asks with no ruling in the log -- the questions still waiting."""
656
657 def ruling_for(self, ask_id: str) -> HubEntry | None:
658 """The ruling that answered *ask_id*, or ``None``."""
659
660
661@runtime_checkable
662class Carrier(Protocol):
663 """Something that can move a message to a recipient."""
664
665 #: What the carrier is called in a receipt and in a trace.
666 name: str
667
668 def present(self, address: str) -> bool:
669 """Can this carrier reach *address* right now?"""
670
671 def deliver(self, message: HubMessage) -> DeliveryReceipt:
672 """Move *message*, or raise."""
673
674
675# ── The hub ──────────────────────────────────────────────────────────────
676
677
678class Hub:
679 """Validate, record, then route. In that order, always."""
680
681 def __init__(self, *, log: HubLog, carriers: tuple[Carrier, ...] = ()) -> None:
682 func = "Hub.__init__"
683 self.log = log
684 self.carriers = tuple(carriers)
685 _dbg("OC_CHAT.MAIN", func,
686 f"over {len(self.carriers)} carrier(s): "
687 f"{', '.join(c.name for c in self.carriers) or 'none'}", 2)
688
689 def send(self, message: HubMessage) -> DeliveryReceipt:
690 """Record *message*, then hand it to the first present carrier.
691
692 The order is the contract. A ruling is checked against its ask
693 BEFORE the record, because a ruling the human rule refuses must
694 leave no trace of having been accepted; everything else is
695 recorded first, because the log is the truth and a carrier that
696 fails afterwards has lost a delivery, not a message.
697
698 Raises:
699 ProvenanceError: SECURITY_ERROR (L1) -- a ruling the human
700 rule refuses. Nothing is recorded and nothing is sent.
701 UnknownAskError: VALIDATION_ERROR (L3) -- a ruling naming
702 something the log does not hold as an ask.
703 CarrierError: EXTERNAL_ERROR (L2) -- no carrier is present
704 for the recipient, or the chosen one raised. The entry
705 stands either way.
706 """
707 func = "Hub.send"
708 if message.kind == KIND_RULING:
709 check_ruling_provenance(self._ask_for(message.in_reply_to or ""), message)
710
711 entry = self.log.record(message)
712 _dbg("OC_CHAT.MAIN", func,
713 f"recorded seq {entry.seq}: {message.kind} -> {message.recipient}", 2)
714
715 for carrier in self.carriers:
716 if not carrier.present(message.recipient):
717 continue
718 try:
719 receipt = carrier.deliver(message)
720 except Exception as exc: # noqa: BLE001 - every carrier failure is one class
721 _dbg("OC_CHAT.MAIN", func,
722 f"EXTERNAL_ERROR: {carrier.name} failed: {exc}", 2)
723 raise CarrierError(
724 f"carrier {carrier.name!r} could not deliver "
725 f"{message.message_id} to {message.recipient}: {exc}. The log "
726 f"entry at seq {entry.seq} stands; the message was sent."
727 ) from exc
728 _dbg("OC_CHAT.MAIN", func,
729 f"{carrier.name} delivered {message.message_id}", 2)
730 return receipt
731
732 names = ", ".join(c.name for c in self.carriers) or "none"
733 _dbg("OC_CHAT.MAIN", func,
734 f"EXTERNAL_ERROR: no carrier present for {message.recipient}", 2)
735 raise CarrierError(
736 f"no carrier is present for {message.recipient}; tried: {names}. "
737 f"The log entry at seq {entry.seq} stands; the message was sent."
738 )
739
740 def attach(self, address: str, callback: "Callable[[HubMessage], None]") -> None:
741 """Register *callback* as the in-process reader for *address*.
742
743 Delegated to the first carrier that accepts attachments, which
744 today is ``oc_chat.core.hub_bus.BusCarrier`` -- a CONSUMER's
745 module, injected through ``carriers=``, which this package neither
746 imports nor names at any depth beyond this sentence. The
747 delegation is duck-typed ON PURPOSE: this module declares what a
748 hub IS, and a hub that imported a specific carrier to offer this
749 method would have the routing decision baked into the contract
750 instead of injected beside it. Plan 272 P4 is the proof that the
751 duck typing was load-bearing rather than decorative: the bus
752 carrier stayed behind when the hub moved, and nothing here
753 changed.
754
755 Raises:
756 AddressError: VALIDATION_ERROR (L3) -- *address* is not one.
757 CarrierError: VALIDATION_ERROR (L3) -- no injected carrier
758 accepts attachments, so there is nowhere to put it.
759 """
760 parse_address(address)
761 self._attachable().attach(address, callback)
762
763 def detach(self, address: str) -> None:
764 """Remove *address*'s in-process reader. See :meth:`attach`."""
765 parse_address(address)
766 self._attachable().detach(address)
767
768 def _attachable(self) -> "Any":
769 """The first carrier exposing ``attach``, or a refusal naming the set."""
770 for carrier in self.carriers:
771 if hasattr(carrier, "attach") and hasattr(carrier, "detach"):
772 return carrier
773 names = ", ".join(c.name for c in self.carriers) or "none"
774 raise CarrierError(
775 f"no injected carrier accepts an attach; tried: {names}. An "
776 "in-process reader needs a carrier that can hold one."
777 )
778
779 def rule(
780 self,
781 ask_id: str,
782 *,
783 answer: str,
784 body: str,
785 provenance: Provenance,
786 now: datetime | None = None,
787 entropy: bytes | None = None,
788 ) -> HubEntry:
789 """Answer the open ask *ask_id* and send the ruling back to its asker.
790
791 The ruling is BUILT from the ask rather than supplied beside it,
792 so its recipient, subject and ``in_reply_to`` cannot disagree
793 with the question it answers.
794
795 Raises:
796 UnknownAskError: VALIDATION_ERROR (L3) -- no such message, it
797 is not an ask, or it already has a ruling.
798 ProvenanceError: SECURITY_ERROR (L1) -- the human rule.
799 """
800 func = "Hub.rule"
801 ask = self._ask_for(ask_id)
802 existing = self.log.ruling_for(ask_id)
803 if existing is not None:
804 raise UnknownAskError(
805 f"ask {ask_id} was already ruled by {existing.message.message_id} "
806 f"({existing.message.answer}); a second ruling would make the log "
807 "ambiguous about which answer was acted on"
808 )
809 ruling = new_message(
810 kind=KIND_RULING,
811 sender=provenance.actor,
812 recipient=ask.sender,
813 subject=ask.subject,
814 body=body,
815 in_reply_to=ask.message_id,
816 answer=answer,
817 provenance=provenance,
818 now=now,
819 entropy=entropy,
820 )
821 check_ruling_provenance(ask, ruling)
822 entry = self.log.record(ruling)
823 _dbg("OC_CHAT.MAIN", func, f"{answer} on {ask_id} (seq {entry.seq})", 2)
824 self._deliver_recorded(ruling)
825 return entry
826
827 def _deliver_recorded(self, message: HubMessage) -> DeliveryReceipt | None:
828 """Route an already-recorded message; a failure is still a refusal."""
829 func = "Hub._deliver_recorded"
830 for carrier in self.carriers:
831 if not carrier.present(message.recipient):
832 continue
833 try:
834 return carrier.deliver(message)
835 except Exception as exc: # noqa: BLE001 - one class for every carrier
836 _dbg("OC_CHAT.MAIN", func,
837 f"EXTERNAL_ERROR: {carrier.name} failed: {exc}", 2)
838 raise CarrierError(
839 f"carrier {carrier.name!r} could not deliver "
840 f"{message.message_id} to {message.recipient}: {exc}. The log "
841 "entry stands; the message was sent."
842 ) from exc
843 raise CarrierError(
844 f"no carrier is present for {message.recipient}. The log entry "
845 "stands; the message was sent."
846 )
847
848 def _ask_for(self, ask_id: str) -> HubMessage:
849 """The ask *ask_id* names, or a refusal saying what it found instead."""
850 for entry in self.log.entries():
851 if entry.message.message_id != ask_id:
852 continue
853 if entry.message.kind != KIND_ASK:
854 raise UnknownAskError(
855 f"{ask_id} is a {entry.message.kind!r}, not an ask; only an "
856 "ask can be ruled on"
857 )
858 return entry.message
859 raise UnknownAskError(f"the hub log holds no message {ask_id!r}")
860
861
862__all__ = [
863 "ACTOR_TYPES",
864 "ADDRESS_OPERATOR_LOCAL",
865 "ADDRESS_OPERATOR_REMOTE",
866 "ADDRESS_ORCHESTRATOR",
867 "CHANNELS",
868 "CROCKFORD_ALPHABET",
869 "FIXED_ADDRESSES",
870 "HUMAN_ACTOR_TYPE",
871 "HUMAN_CHANNELS",
872 "KIND_ALERT",
873 "KIND_ASK",
874 "KIND_DISPATCH",
875 "KIND_INSTRUCTION",
876 "KIND_REPORT",
877 "KIND_RULING",
878 "REQUIRES_HUMAN",
879 "REQUIRES_ORCHESTRATOR",
880 "RULING_ANSWERS",
881 "SUPPORTED_KINDS",
882 "SUPPORTED_REQUIRES",
883 "AddressError",
884 "Carrier",
885 "CarrierError",
886 "DeliveryReceipt",
887 "Hub",
888 "HubEntry",
889 "HubError",
890 "HubLog",
891 "HubMessage",
892 "KindError",
893 "Provenance",
894 "ProvenanceError",
895 "UnknownAskError",
896 "check_kind",
897 "check_ruling_provenance",
898 "new_message",
899 "new_ulid",
900 "parse_address",
901 "validate_agent_id",
902]
DeliveryReceipt deliver(self, HubMessage message)
Definition hub.py:671
bool present(self, str address)
Definition hub.py:668
Definition hub.py:469
DeliveryReceipt|None _deliver_recorded(self, HubMessage message)
Definition hub.py:827
HubEntry rule(self, str ask_id, *, str answer, str body, Provenance provenance, datetime|None now=None, bytes|None entropy=None)
Definition hub.py:788
None __init__(self, *, HubLog log, tuple[Carrier,...] carriers=())
Definition hub.py:681
None attach(self, str address, "Callable[[HubMessage], None]" callback)
Definition hub.py:740
HubMessage _ask_for(self, str ask_id)
Definition hub.py:848
"Any" _attachable(self)
Definition hub.py:768
DeliveryReceipt send(self, HubMessage message)
Definition hub.py:689
None detach(self, str address)
Definition hub.py:763
tuple[HubEntry,...] open_asks(self, *, str|None recipient=None)
Definition hub.py:654
tuple[HubEntry,...] entries(self, *, str|None kind=None, str|None recipient=None, str|None subject=None, int|None since_seq=None)
Definition hub.py:651
HubEntry|None ruling_for(self, str ask_id)
Definition hub.py:657
HubEntry record(self, HubMessage message)
Definition hub.py:641
"HubMessage" from_mapping(cls, Mapping[str, object] raw)
Definition hub.py:438
dict[str, object] as_dict(self)
Definition hub.py:421
dict[str, object] as_dict(self)
Definition hub.py:357
"Provenance" from_mapping(cls, Mapping[str, object] raw)
Definition hub.py:367
str new_ulid(datetime now, *, bytes|None entropy=None)
Definition hub.py:209
str parse_address(str value)
Definition hub.py:271
None check_ruling_provenance(HubMessage ask, HubMessage ruling)
Definition hub.py:572
HubMessage new_message(*, str kind, str sender, str recipient, str body, Provenance provenance, str|None subject=None, str|None in_reply_to=None, str|None requires=None, str|None answer=None, datetime|None now=None, bytes|None entropy=None)
Definition hub.py:503
str|None _optional_str(object value)
Definition hub.py:464
str _encode(int value, int length)
Definition hub.py:201
None check_kind(str kind)
Definition hub.py:298