7The registry backend for the hub log: one message, one operation-log row.
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.
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.
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.
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.
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
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.
65 L2 - lifecycle events (a message recorded)
66 L3 - semantic details (a refusal, a filter)
67 L4 - deep tracing (module load)
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.
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
99- oc_chat.registry.api (the Registry facade)
100- oc_chat.registry.domain (the typed hub operation)
103from __future__
import annotations
108from oc_diagnostics
import _dbg
110from oc_orchestrator.hub
import (
120_dbg(
"OC_CHAT.MAIN",
"module",
"hub_log loaded", 4)
124HUB_OPERATION_TYPE =
"hub.message"
131_PLAN_ID_RE = re.compile(
r"^[0-9]{3,}-[A-Za-z0-9][A-Za-z0-9_\-.]*$")
138_ACTOR_TYPE_BY_SLOT_KIND = {
139 "operator":
"human_operator",
140 "orchestrator":
"orchestrator_agent",
141 "worker":
"worker_agent",
146 """The actor slot this log writes as is not registered in this registry."""
150 """The hub's log over the registry's operation log."""
152 def __init__(self, registry: Registry, *, actor_slot: str) ->
None:
153 func =
"RegistryHubLog.__init__"
156 _dbg(
"OC_CHAT.MAIN", func, f
"as {actor_slot}", 2)
160 def record(self, message: HubMessage) -> HubEntry:
161 """Write *message* as one operation-log row and return its entry.
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.
169 func =
"RegistryHubLog.record"
173 message=message.as_dict(),
176 actor_type=actor_type,
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)
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.
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
202 func =
"RegistryHubLog.entries"
205 "SELECT state_version, args_json FROM operation_log "
206 "WHERE operation_type = ?"
208 parameters: list[object] = [HUB_OPERATION_TYPE]
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"
218 rows = conn.execute(sql, tuple(parameters)).fetchall()
221 HubEntry(seq=int(row[
"state_version"]), message=
_message_from_row(row[
"args_json"]))
227 if (recipient
is None or entry.message.recipient == recipient)
228 and (subject
is None or entry.message.subject == subject)
230 _dbg(
"OC_CHAT.MAIN", func, f
"{len(chosen)} of {len(entries)} row(s)", 3)
233 def open_asks(self, *, recipient: str |
None =
None) -> tuple[HubEntry, ...]:
234 """Asks with no ruling in the log -- the questions still waiting."""
236 entry.message.in_reply_to
for entry
in self.
entries(kind=KIND_RULING)
240 for entry
in self.
entries(kind=KIND_ASK, recipient=recipient)
241 if entry.message.message_id
not in ruled
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:
254 """The actor type the storage layer will demand for this slot."""
257 "SELECT slot_kind FROM agents WHERE slot = ?",
262 f
"actor slot {self.actor_slot!r} is not registered in this "
263 "registry; the hub cannot write a row it cannot attribute"
265 slot_kind = str(row[
"slot_kind"])
267 return _ACTOR_TYPE_BY_SLOT_KIND[slot_kind]
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))}"
276 """A read connection through the storage backend."""
277 connect = getattr(self.
registry.backend,
"connect",
None)
279 raise HubError(
"the registry backend does not expose read connections")
284 """*subject* if it is plan-shaped, else ``None``. See the constant above."""
285 if subject
and _PLAN_ID_RE.fullmatch(subject):
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):
296 f
"a {HUB_OPERATION_TYPE} row carries no message object; the payload "
297 f
"has {sorted(payload)}"
299 return HubMessage.from_mapping(stored)
303 "HUB_OPERATION_TYPE",
305 "UnregisteredSenderError",
HubEntry|None ruling_for(self, str ask_id)
tuple[HubEntry,...] entries(self, *, str|None kind=None, str|None recipient=None, str|None subject=None, int|None since_seq=None)
None __init__(self, Registry registry, *, str actor_slot)
HubEntry record(self, HubMessage message)
tuple[HubEntry,...] open_asks(self, *, str|None recipient=None)
str|None _plan_subject(str|None subject)
HubMessage _message_from_row(object args_json)