1"""The session-alert lane: the events worth waking an operator for, and their
4Plan 237 (CAPS-01), built to the shape plan 231 established for
5``worker-reports``; plan 248 (CAPS-03) added the fourth kind. A running OC Chat
6session that crosses a cost checkpoint, stops at the dollar hard cap, pauses at
7the per-turn tool-call checkpoint, or halts a turn on a detected stall
8writes ONE event into ``comms/lanes/session-alerts/`` and stops there: the
9product holds no credential, knows nothing about connectors, and touches no
10other lane. This module is the other half — the orchestrator-side reader and
11the grant-checked, egress-filtered relay that puts the alert on the operator's
14**This module is the schema authority for the lane.** The writer lives in
15``oc_chat/oc_chat/core/session_alerts.py`` and deliberately does NOT import
16this package — that is what keeps the product credential-free. The two sides
17agree by written contract rather than by a shared import, and the contract is
18:class:`SessionAlertEvent`::
21 "schema_version": "1.0",
22 "event_id": "<26-character ULID, time-first so lexical == arrival>",
23 "lane": "session-alerts",
24 "direction": "inbound",
26 "created_at_utc": "2026-09-06T12:00:00Z",
27 "session_id": "<the chat session's stable uuid4>",
28 "kind": "cost_threshold" | "cost_hard_cap"
29 | "tool_call_checkpoint" | "stall_halt",
30 "threshold_usd": 2.0, // null for a tool-call checkpoint
31 // and for a stall halt
33 "unpriced_samples": 3,
34 "turn_tool_calls": 100,
35 "summary": "<the one line the operator reads, numbers only>",
36 "relayed_at_utc": null,
37 "relay_chunk_count": null
40File naming follows the §3 bus convention, ``<utc-stamp>_product_<ulid>.json``.
42Two differences from the worker-report lane are deliberate:
44* **One line, never chunked.** A worker report carries the worker's whole
45 closing summary and must be chunked to survive Telegram's 4096-character
46 limit. An alert is a single generated sentence of numbers, so the relay sends
47 exactly one message per event and records ``relay_chunk_count = 1``. If a
48 summary ever grew past the transport limit the send would fail and the event
49 would stay unrelayed — a loud failure, not a truncation.
50* **No content digest.** A worker report's ``summary_sha256`` exists because the
51 text is a human's words and a post-hoc edit would put words in their mouth.
52 An alert's summary is derived from the numeric fields beside it, so the
53 relay re-derives nothing and asserts nothing about authorship; what it
54 forwards is the stored line, fenced, exactly as it forwards a worker's.
58- oc_connect.audit (AuditStore, content_hash, timestamp, utc_now - audits relay outcomes and stamps relayed_at_utc)
59- oc_connect.delivery (OutboundSender - the grant-checked send path every relayed alert crosses)
60- oc_connect.egress (require_safe, EgressBlockedError - scans the alert summary before it is queued)
61- oc_diagnostics (_dbg structured logger)
62- option_c_connector_contracts (lane - the session-alerts lane's declared direction and risk ceiling)
63- pydantic (BaseModel, ConfigDict, field_validator - the SessionAlertEvent schema)
65from __future__
import annotations
69import secrets
as _secrets
70from collections.abc
import Callable
71from dataclasses
import dataclass
72from datetime
import datetime
73from pathlib
import Path
74from typing
import Literal
76from oc_diagnostics
import _dbg
77from option_c_connector_contracts
import lane
78from pydantic
import BaseModel, ConfigDict, field_validator
80from .audit
import AuditStore, timestamp, utc_now
81from .delivery
import OutboundSender
82from .egress
import EgressBlockedError, require_safe
85SESSION_ALERT_LANE =
"session-alerts"
88SESSION_ALERT_SOURCE =
"product"
90SESSION_ALERT_SCHEMA_VERSION =
"1.0"
92SessionAlertKind = Literal[
93 "cost_threshold",
"cost_hard_cap",
"tool_call_checkpoint",
"stall_halt",
99SESSION_ALERT_BEGIN =
"<<<BEGIN SESSION ALERT {event_id}>>>"
100SESSION_ALERT_END =
"<<<END SESSION ALERT {event_id}>>>"
103NEUTRALISED_MARKER =
"<< <"
107 """A session-alert event is unreadable, malformed, or already consumed."""
111 """The relay could not put one event's summary on the transport."""
115 """One session alert at rest in ``lanes/session-alerts/``."""
117 model_config = ConfigDict(frozen=
True, extra=
"forbid", strict=
True)
121 lane: Literal[
"session-alerts"]
122 direction: Literal[
"inbound"]
123 risk_class: Literal[
"low"]
126 kind: SessionAlertKind
127 threshold_usd: float |
None =
None
129 unpriced_samples: int
132 relayed_at_utc: str |
None =
None
133 relay_chunk_count: int |
None =
None
136 "schema_version",
"event_id",
"created_at_utc",
"session_id",
"summary",
140 if not value.strip():
141 raise ValueError(
"must not be blank")
144 @field_validator("total_usd", "threshold_usd")
147 if value
is not None and value < 0:
148 raise ValueError(
"a money field must not be negative")
151 @field_validator("unpriced_samples", "turn_tool_calls")
155 raise ValueError(
"a count must not be negative")
160 """True once this alert reached the transport."""
165 """Return the one line an operator reads for this alert, fenced."""
166 state =
"relayed" if event.is_relayed
else "unrelayed"
167 threshold =
"-" if event.threshold_usd
is None else f
"${event.threshold_usd:.2f}"
169 f
"[{state}] {event.event_id} {event.created_at_utc} "
170 f
"lane={event.lane} kind={event.kind} threshold={threshold} "
171 f
"session={event.session_id}"
173 body = event.summary.replace(
"<<<", NEUTRALISED_MARKER)
174 begin = SESSION_ALERT_BEGIN.format(event_id=event.event_id)
175 end = SESSION_ALERT_END.format(event_id=event.event_id)
176 return f
"{header}\n{begin}\n{body}\n{end}"
180 path.parent.mkdir(parents=
True, exist_ok=
True)
181 temporary = path.with_name(f
".tmp-{path.name}-{_secrets.token_hex(8)}")
183 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
184 handle.write(payload)
187 os.fsync(handle.fileno())
188 os.replace(temporary, path)
190 if temporary.exists():
198 """Read-and-mark access to one ``lanes/session-alerts/`` directory.
200 Lane separation is physical: this store is handed the lane directory and
201 has no path arithmetic that could reach ``inbound/`` or ``outbound/``.
206 directory: str | Path,
208 now: Callable[[], datetime] = utc_now,
213 def paths(self) -> tuple[Path, ...]:
214 """Return event files in arrival (filename) order, ignoring dotfiles."""
220 for path
in self.
directory.glob(
"*.json")
221 if path.is_file()
and not path.name.startswith(
".")
225 def _read(self, path: Path) -> SessionAlertEvent:
227 return SessionAlertEvent.model_validate_json(
228 path.read_text(encoding=
"utf-8")
230 except (OSError, UnicodeDecodeError, ValueError)
as exc:
232 f
"could not parse session alert event at {path} "
233 f
"({type(exc).__name__})"
236 def list_events(self, *, relayed: bool |
None =
None) -> tuple[SessionAlertEvent, ...]:
237 """Return stored events in arrival order, optionally by relay state."""
238 events = tuple(self.
_read(path)
for path
in self.
paths())
241 return tuple(event
for event
in events
if event.is_relayed
is relayed)
244 """Return the file holding one event or fail with its public id."""
245 suffix = f
"_{event_id}.json"
246 for path
in self.
paths():
247 if path.name.endswith(suffix):
251 def get(self, event_id: str) -> SessionAlertEvent:
252 """Return one stored event or fail with its public id."""
255 def mark_relayed(self, event_id: str, *, chunk_count: int = 1) -> SessionAlertEvent:
256 """Record that one event's summary reached the transport."""
258 raise ValueError(
"a relayed event must have sent at least one message")
260 event = self.
_read(path)
263 f
"session alert event {event_id!r} was already relayed at "
264 f
"{event.relayed_at_utc}"
266 updated = event.model_copy(
268 "relayed_at_utc": timestamp(self.
_now()),
269 "relay_chunk_count": chunk_count,
274 json.dumps(updated.model_dump(mode=
"json"), indent=2, sort_keys=
True),
277 "OC_CONNECT.SESSION_ALERTS",
279 f
"event={event_id} kind={event.kind}",
285@dataclass(frozen=True)
287 """What the relay did with one alert, in terms a CLI can print."""
296 """Forward unrelayed session alerts through the standard send path.
298 Every message still crosses the plan-219 pipeline — grant check, egress
299 scan, durable outbox, audit — because the relay delegates to
300 :class:`~oc_connect.delivery.OutboundSender` rather than reimplementing it.
306 store: SessionAlertStore,
307 sender: OutboundSender,
312 risk_class: str |
None =
None,
313 now: Callable[[], datetime] = utc_now,
326 event: SessionAlertEvent,
330 error_code: str |
None =
None,
332 descriptor = lane(SESSION_ALERT_LANE)
333 from .audit
import content_hash
336 connector=
"telegram",
340 digest=content_hash(event.summary),
341 idempotency_key=event.event_id,
343 error_code=error_code,
344 lane=SESSION_ALERT_LANE,
345 direction=descriptor.direction,
346 risk_class=descriptor.risk_ceiling,
349 def relay_one(self, event: SessionAlertEvent) -> SessionAlertOutcome:
350 """Relay one alert, or leave it unrelayed and raise."""
353 require_safe(event.summary)
354 except EgressBlockedError:
355 self.
_audit(event,
"blocked", attempts=0, error_code=
"egress_finding")
357 "OC_CONNECT.SESSION_ALERTS",
359 f
"SECURITY_ERROR: blocked session alert event={event.event_id}",
364 receipt = self.
sender.send(
370 idempotency_key=event.event_id,
372 if receipt.outcome !=
"sent":
373 self.
_audit(event,
"failed", attempts=1, error_code=receipt.outcome)
375 f
"session alert {event.event_id!r} was not delivered "
376 f
"(outcome {receipt.outcome!r}); the event stays unrelayed and "
377 "re-running resumes from the durable outbox"
379 self.
store.mark_relayed(event.event_id)
380 self.
_audit(event,
"sent", attempts=1)
382 "OC_CONNECT.SESSION_ALERTS",
384 f
"relayed event={event.event_id} kind={event.kind}",
388 event_id=event.event_id,
390 total_usd=event.total_usd,
391 audit_id=receipt.audit_id,
395 """Relay every unrelayed alert, oldest first, stopping on the first refusal."""
398 for event
in self.
store.list_events(relayed=
False)
403 "NEUTRALISED_MARKER",
404 "SESSION_ALERT_BEGIN",
406 "SESSION_ALERT_LANE",
407 "SESSION_ALERT_SCHEMA_VERSION",
408 "SESSION_ALERT_SOURCE",
412 "SessionAlertOutcome",
414 "SessionAlertRelayError",
float|None _money_is_not_negative(cls, float|None value)
str _required_text_is_nonblank(cls, str value)
int _counts_are_not_negative(cls, int value)
None _audit(self, SessionAlertEvent event, str outcome, *, int attempts, str|None error_code=None)
None __init__(self, *, SessionAlertStore store, OutboundSender sender, AuditStore audit, str grantee, str destination, str grant_id, str|None risk_class=None, Callable[[], datetime] now=utc_now)
SessionAlertOutcome relay_one(self, SessionAlertEvent event)
tuple[SessionAlertOutcome,...] relay_pending(self)
SessionAlertEvent mark_relayed(self, str event_id, *, int chunk_count=1)
None __init__(self, str|Path directory, *, Callable[[], datetime] now=utc_now)
SessionAlertEvent _read(self, Path path)
tuple[Path,...] paths(self)
Path path_for(self, str event_id)
tuple[SessionAlertEvent,...] list_events(self, *, bool|None relayed=None)
SessionAlertEvent get(self, str event_id)
None _atomic_write(Path path, str payload)
str render_event(SessionAlertEvent event)