oc_chat
Loading...
Searching...
No Matches
hub_log.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/hub_log.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_259_hub_log.py"
6"""
7The registry backend for the hub log: one message, one operation-log row.
8
9Purpose
10-------
11Plan 259 P4. The hub has one log contract and two backends, and this is
12the one oc_chat plugs in. It lives on the REGISTRY side on purpose,
13exactly as ``registry/queue_backend.py`` does: the operation log already
14carries actor, actor type, credential id and an append-only guarantee
15enforced by a database trigger, so the hub inherits those instead of
16growing a second copy of them.
17
18A hub message is ONE row of ``operation_log`` with
19``operation_type='hub.message'``, ``command`` = the hub kind and
20``related_plan_id`` = the subject when the subject is a plan id. There is
21no projection: a message changes no projected state, it IS the state, and
22``SQLiteBackend._apply_domain_projection`` is an ``if``/``elif`` chain
23with no ``else``, so an unmatched type projects nothing. **No projector
24was added, because none is required** -- a witness asserts the
25observable half of that.
26
27**The row's signature and the message's claim are different things.**
28``actor_type`` and ``hmac_key_id`` are written by the storage layer from
29the SLOT, and a caller cannot choose them; the message's ``provenance``
30is a payload field a caller writes freely. The hub's human rule is
31enforced on the claim, in ``core/hub.check_ruling_provenance``, and this
32module does not second-guess it -- but the row beside it records who the
33process was, which is the cross-check the design note asked for.
34
35The measured preflight fact
36---------------------------
37A ``human_operator`` row written from a CLI process that supplies no
38``auth_proof`` **is accepted, unsigned**. ``Registry._preflight_auth``
39(``registry/api.py:115``) runs the role guard and returns ``None`` the
40moment ``auth_proof`` is ``None``; ``hub.message`` matches no entry in
41``ROLE_ALLOWSETS`` and no prefix in ``PREFIX_ALLOWSETS``, so the guard
42permits all three slot kinds. The row still lands carrying an
43``hmac_key_id``, because the storage layer falls back to the slot's
44ACTIVE credential when the caller supplies none -- so that key id names a
45credential which did not sign this row.
46
47So the registry mode's signature is a **claim**, like the file mode's,
48and the hub's own human rule is what does the work in both. That is a
49finding, not a design; it is filed rather than fixed here, because
50demanding a credential would be a security-layer change and those are
51the operator's.
52
53Responsibilities
54----------------
55- :class:`RegistryHubLog` -- the four :class:`~oc_orchestrator.hub.HubLog`
56 operations over the operation log.
57- :data:`HUB_OPERATION_TYPE` -- the one operation type, pinned as a
58 constant so a witness can assert it.
59- :class:`UnregisteredSenderError` -- the adapter's named refusal.
60
61Diagnostics
62-----------
63Domain: OC_CHAT.MAIN
64Levels:
65 L2 - lifecycle events (a message recorded)
66 L3 - semantic details (a refusal, a filter)
67 L4 - deep tracing (module load)
68
69Contracts
70---------
71- This module MUTATES only through ``registry.domain.hub_message``: no
72 SQL write, no direct table update.
73- The READ is one ``SELECT`` in this file rather than a fifth function in
74 ``registry/readers.py``. That is the honest place: ``readers.py``
75 returns ``registry/dataclasses.py`` types, and a hub reader must return
76 :class:`~oc_orchestrator.hub.HubEntry` -- a DISPATCH-layer type. Putting
77 that import into the general reader library would push a dispatch type
78 into every consumer of it, including the registry CLI. The adapter is
79 already the one place the two vocabularies meet, so the read lives
80 here, exactly as ``queue_backend`` keeps its own ``plans`` projection
81 query rather than widening ``readers``.
82- The import direction is registry -> dispatch, which is the permitted
83 one and the one ``registry/mirror_observer.py`` and
84 ``registry/queue_backend.py`` already take. Nothing in the dispatch
85 layer imports this module; the CLI reaches it through one lazy import
86 behind ``--backend registry``.
87- ``seq`` is the row's ``state_version``: monotonic within the store, as
88 the contract requires. It is NOT comparable with the file log's
89 line count, and the equivalence witness compares the FOLD rather than
90 the numbering for that reason.
91
92Dependencies
93------------
94- oc_diagnostics (structured debug logging via _dbg)
95- oc_orchestrator.hub (the contract; moved there by plan 272, and
96 named directly rather than through the shim because this module is
97 the registry-side backend of the seam, not a caller that always
98 spelled the old path)
99- oc_chat.registry.api (the Registry facade)
100- oc_chat.registry.domain (the typed hub operation)
101"""
102
103from __future__ import annotations
104
105import json
106import re
107
108from oc_diagnostics import _dbg
109
110from oc_orchestrator.hub import (
111 KIND_ASK,
112 KIND_RULING,
113 HubEntry,
114 HubError,
115 HubMessage,
116)
117from oc_chat.registry.api import Registry
118from oc_chat.registry.domain import hub_message
119
120_dbg("OC_CHAT.MAIN", "module", "hub_log loaded", 4)
121
122
123#: The one operation type this adapter writes and reads.
124HUB_OPERATION_TYPE = "hub.message"
125
126#: What ``related_plan_id`` will accept: a plan id is ``NNN-stem``. A
127#: subject outside this shape stays in the payload and leaves the column
128#: NULL -- the column is plan-shaped, and the design note ยง5 names that
129#: as the seam a second work-item kind will find.
130# OCT-LINT: disable=no_hardcoded_secrets reason="Plan-id shape regex (NNN-stem) for the related_plan_id column; no credential content"
131_PLAN_ID_RE = re.compile(r"^[0-9]{3,}-[A-Za-z0-9][A-Za-z0-9_\-.]*$")
132
133#: The registry's actor type for each slot kind, mirroring
134#: ``backends/sqlite._ACTOR_TYPES_BY_SLOT_KIND``. The storage layer
135#: asserts the same mapping before commit; deriving it here is what lets
136#: the adapter supply the RIGHT value rather than make the caller guess
137#: and be refused with ``ActorTypeMismatchError``.
138_ACTOR_TYPE_BY_SLOT_KIND = {
139 "operator": "human_operator",
140 "orchestrator": "orchestrator_agent",
141 "worker": "worker_agent",
142}
143
144
146 """The actor slot this log writes as is not registered in this registry."""
147
148
150 """The hub's log over the registry's operation log."""
151
152 def __init__(self, registry: Registry, *, actor_slot: str) -> None:
153 func = "RegistryHubLog.__init__"
154 self.registry = registry
155 self.actor_slot = actor_slot
156 _dbg("OC_CHAT.MAIN", func, f"as {actor_slot}", 2)
157
158 # -- writes -----------------------------------------------------------
159
160 def record(self, message: HubMessage) -> HubEntry:
161 """Write *message* as one operation-log row and return its entry.
162
163 Raises:
164 UnregisteredSenderError: VALIDATION_ERROR (L3) -- the actor
165 slot is not in ``agents``. The registry would refuse it
166 anyway; refusing here names the slot instead of raising
167 an ``UnknownActorError`` from three frames down.
168 """
169 func = "RegistryHubLog.record"
170 actor_type = self._actor_type()
171 row = hub_message(
172 self.registry,
173 message=message.as_dict(),
174 kind=message.kind,
175 actor_slot=self.actor_slot,
176 actor_type=actor_type, # type: ignore[arg-type]
177 related_plan_id=_plan_subject(message.subject),
178 )
179 _dbg("OC_CHAT.MAIN", func,
180 f"{message.kind} as {self.actor_slot}/{actor_type} "
181 f"(state_version {row.state_version})", 2)
182 return HubEntry(seq=int(row.state_version), message=message)
183
184 # -- reads ------------------------------------------------------------
185
187 self,
188 *,
189 kind: str | None = None,
190 recipient: str | None = None,
191 subject: str | None = None,
192 since_seq: int | None = None,
193 ) -> tuple[HubEntry, ...]:
194 """Recorded entries, oldest first, narrowed by whatever is given.
195
196 ``kind`` narrows in SQL, because it is the ``command`` column;
197 the other two narrow in Python, because they live inside the
198 payload and a JSON predicate in SQL would be a parser in
199 disguise -- the same call the queue adapter made about its own
200 free-text column.
201 """
202 func = "RegistryHubLog.entries"
203 sql = (
204 # OCT-LINT: disable=no_hardcoded_secrets reason="Static SQL projection over the operation log; no credential content"
205 "SELECT state_version, args_json FROM operation_log "
206 "WHERE operation_type = ?"
207 )
208 parameters: list[object] = [HUB_OPERATION_TYPE]
209 if kind is not None:
210 sql += " AND command = ?"
211 parameters.append(kind)
212 if since_seq is not None:
213 sql += " AND state_version > ?"
214 parameters.append(int(since_seq))
215 sql += " ORDER BY state_version"
216
217 with self._connect() as conn:
218 rows = conn.execute(sql, tuple(parameters)).fetchall()
219
220 entries = [
221 HubEntry(seq=int(row["state_version"]), message=_message_from_row(row["args_json"]))
222 for row in rows
223 ]
224 chosen = tuple(
225 entry
226 for entry in entries
227 if (recipient is None or entry.message.recipient == recipient)
228 and (subject is None or entry.message.subject == subject)
229 )
230 _dbg("OC_CHAT.MAIN", func, f"{len(chosen)} of {len(entries)} row(s)", 3)
231 return chosen
232
233 def open_asks(self, *, recipient: str | None = None) -> tuple[HubEntry, ...]:
234 """Asks with no ruling in the log -- the questions still waiting."""
235 ruled = {
236 entry.message.in_reply_to for entry in self.entries(kind=KIND_RULING)
237 }
238 return tuple(
239 entry
240 for entry in self.entries(kind=KIND_ASK, recipient=recipient)
241 if entry.message.message_id not in ruled
242 )
243
244 def ruling_for(self, ask_id: str) -> HubEntry | None:
245 """The ruling that answered *ask_id*, or ``None``."""
246 for entry in self.entries(kind=KIND_RULING):
247 if entry.message.in_reply_to == ask_id:
248 return entry
249 return None
250
251 # -- internals --------------------------------------------------------
252
253 def _actor_type(self) -> str:
254 """The actor type the storage layer will demand for this slot."""
255 with self._connect() as conn:
256 row = conn.execute(
257 "SELECT slot_kind FROM agents WHERE slot = ?",
258 (self.actor_slot,),
259 ).fetchone()
260 if row is None:
262 f"actor slot {self.actor_slot!r} is not registered in this "
263 "registry; the hub cannot write a row it cannot attribute"
264 )
265 slot_kind = str(row["slot_kind"])
266 try:
267 return _ACTOR_TYPE_BY_SLOT_KIND[slot_kind]
268 except KeyError:
270 f"actor slot {self.actor_slot!r} has slot_kind {slot_kind!r}, "
271 f"which maps to no actor type; the registry knows "
272 f"{', '.join(sorted(_ACTOR_TYPE_BY_SLOT_KIND))}"
273 ) from None
274
275 def _connect(self) -> object:
276 """A read connection through the storage backend."""
277 connect = getattr(self.registry.backend, "connect", None)
278 if connect is None:
279 raise HubError("the registry backend does not expose read connections")
280 return connect()
281
282
283def _plan_subject(subject: str | None) -> str | None:
284 """*subject* if it is plan-shaped, else ``None``. See the constant above."""
285 if subject and _PLAN_ID_RE.fullmatch(subject):
286 return subject
287 return None
288
289
290def _message_from_row(args_json: object) -> HubMessage:
291 """Rebuild the message one ``hub.message`` payload carried."""
292 payload = json.loads(str(args_json or "{}"))
293 stored = payload.get("message")
294 if not isinstance(stored, dict):
295 raise HubError(
296 f"a {HUB_OPERATION_TYPE} row carries no message object; the payload "
297 f"has {sorted(payload)}"
298 )
299 return HubMessage.from_mapping(stored)
300
301
302__all__ = [
303 "HUB_OPERATION_TYPE",
304 "RegistryHubLog",
305 "UnregisteredSenderError",
306]
HubEntry|None ruling_for(self, str ask_id)
Definition hub_log.py:244
tuple[HubEntry,...] entries(self, *, str|None kind=None, str|None recipient=None, str|None subject=None, int|None since_seq=None)
Definition hub_log.py:193
None __init__(self, Registry registry, *, str actor_slot)
Definition hub_log.py:152
HubEntry record(self, HubMessage message)
Definition hub_log.py:160
tuple[HubEntry,...] open_asks(self, *, str|None recipient=None)
Definition hub_log.py:233
str|None _plan_subject(str|None subject)
Definition hub_log.py:283
HubMessage _message_from_row(object args_json)
Definition hub_log.py:290