Files
DECNET/decnet/ttp/factory.py
anti c3c5813211 feat(ttp): E.1.3+E.1.4 Tagger ABC and composite factory contract
Third and fourth TTP-tagging contract commits, plus a scoped subset
of the E.2.4 conformance tests covering the contract surface shipped
here (full hypothesis-fuzz suite still lands with E.2.4).

E.1.3 — decnet/ttp/base.py
- TaggerEvent NamedTuple: source_kind, source_id, attacker_uuid,
  identity_uuid, session_id, decky_id, opaque payload.
- Tagger(ABC) with abstract async tag(); class-level name and
  HANDLES: frozenset[str] (default empty so a misconfigured subclass
  is loudly idle, not loudly noisy).
- TolerantTagger(Tagger): concrete tag() wraps abstract _tag_impl()
  in try/except Exception (deliberately not BaseException — so
  KeyboardInterrupt / SystemExit / asyncio.CancelledError propagate
  and the worker can shut down cleanly). Swallowed exceptions log
  at WARNING with exc_info, never ERROR — absence is the steady
  state, not a bug. Subclasses override _tag_impl, never tag — the
  tolerance contract is enforced in the base class, not on trust.
- KNOWN_SOURCE_KINDS: Final[frozenset[str]] enumerating every
  source_kind a producer is allowed to emit. Closed-by-enumeration
  at the runtime layer; the composite tagger keys its WARNING/INFO
  bridge off this constant to surface the silent-drop trap from
  the design doc (lines 160–195).

E.1.4 — decnet/ttp/factory.py
- get_tagger() reads DECNET_TTP_TAGGER_TYPE (default 'composite');
  unknown values raise ValueError with the known-list. Mirrors
  decnet.intel.factory and decnet.clustering.factory.
- _KNOWN = ('composite',). Per-lifter classes (E.1.6) are children
  of the composite, not standalone tagger types.
- CompositeTagger(Tagger): pre-computes a dict[str, list[Tagger]]
  dispatch index from each lifter's HANDLES; fans events out
  concurrently with asyncio.gather and concatenates results.
  Empty lifters=[] is the legal contract-phase state — E.1.6
  wires the real lifters in.
- Unhandled-event observability: source_kind in KNOWN_SOURCE_KINDS
  but no lifter claims it -> WARNING once per kind per process
  (missed E.1.6 update). Unknown kind -> INFO once per kind per
  process (future-feature telemetry, by design). Per-process dedup
  via plain set; E.1.6 may swap in a proper rate-limiter once
  production traffic shapes are known.

Tests — tests/ttp/test_base.py, tests/ttp/test_factory.py
- Tagger / TolerantTagger abstractness, missing-tag-impl rejection,
  WARNING-not-ERROR log level, propagation of KeyboardInterrupt /
  SystemExit / asyncio.CancelledError.
- Factory env-var routing, unknown-name ValueError, dispatch-index
  correctness, only-claiming-lifter invocation, WARNING-once for
  known-but-unclaimed kinds, INFO-once for unknown kinds, result
  concatenation across lifters.

Mypy clean under .311/bin/mypy --ignore-missing-imports.
2026-05-01 06:20:10 -04:00

122 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tagger factory + composite tagger.
Contract step E.1.4 of ``development/TTP_TAGGING.md``. Mirrors the
provider-subpackage convention used by :mod:`decnet.intel.factory` and
:mod:`decnet.clustering.factory`: callers obtain the active tagger via
:func:`get_tagger` rather than instantiating a concrete class directly.
The composite tagger is the only shippable tagger type — per-lifter
classes (E.1.6) are children of the composite, not standalone tagger
``DECNET_TTP_TAGGER_TYPE`` values.
Configuration:
* ``DECNET_TTP_TAGGER_TYPE`` — which tagger to instantiate. Default
``"composite"``. Unknown values raise :class:`ValueError` so a typo
in ``decnet.ini`` surfaces immediately rather than silently falling
back.
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Final
from decnet.ttp.base import KNOWN_SOURCE_KINDS, Tagger, TaggerEvent
from decnet.web.db.models.ttp import TTPTag
_log = logging.getLogger(__name__)
_KNOWN: Final[tuple[str, ...]] = ("composite",)
_DEFAULT: Final[str] = "composite"
class CompositeTagger(Tagger):
"""Fans an event out to every lifter that claims its ``source_kind``.
The composite is the runtime end of the closed-by-enumeration
bridge described in :mod:`decnet.ttp.base`: when an event arrives
with a ``source_kind`` no lifter claims, the composite emits a
structured log line so the silent-drop trap from the design doc
becomes observable.
During the contract phase (this commit) ``lifters=[]`` is the
legal state — E.1.6 wires the real per-source lifters in.
"""
name = "composite"
# The composite itself accepts every event; per-kind dispatch is
# delegated to children. Empty here is "n/a, computed from
# children" — the dispatch index below is what actually drives
# the fan-out.
HANDLES: frozenset[str] = frozenset()
def __init__(self, lifters: list[Tagger]) -> None:
self._lifters: list[Tagger] = list(lifters)
index: dict[str, list[Tagger]] = {}
for lifter in self._lifters:
for kind in lifter.HANDLES:
index.setdefault(kind, []).append(lifter)
self._by_kind: dict[str, list[Tagger]] = index
# Per-process dedup state so a flood of one unknown kind
# produces one log line, not one per event. A simple set
# is fine for the contract; E.1.6 may swap in a proper
# rate-limiter once production traffic shapes are known.
self._warned_known: set[str] = set()
self._informed_unknown: set[str] = set()
async def tag(self, event: TaggerEvent) -> list[TTPTag]:
lifters = self._by_kind.get(event.source_kind, [])
if not lifters:
self._log_unhandled(event.source_kind)
return []
results = await asyncio.gather(*(t.tag(event) for t in lifters))
out: list[TTPTag] = []
for tags in results:
out.extend(tags)
return out
def _log_unhandled(self, source_kind: str) -> None:
if source_kind in KNOWN_SOURCE_KINDS:
if source_kind not in self._warned_known:
self._warned_known.add(source_kind)
# Producer ships a kind that *should* be handled but
# no lifter claims it — almost certainly a missed
# E.1.6 update. Loud once per kind per process.
_log.warning(
"composite tagger: no lifter claims known "
"source_kind=%r; events will be dropped until a "
"lifter is registered",
source_kind,
)
else:
if source_kind not in self._informed_unknown:
self._informed_unknown.add(source_kind)
# Telemetry from a future feature, no lifter yet, by
# design (lines 160195 of the design doc). INFO once
# per process; never an error.
_log.info(
"composite tagger: unknown source_kind=%r "
"(not in KNOWN_SOURCE_KINDS); ignoring",
source_kind,
)
def get_tagger() -> Tagger:
"""Return the configured tagger instance.
Lazy package layout: the composite is constructed with an empty
lifter list during the contract phase. E.1.6 will replace this
with explicit lifter wiring; callers don't change.
"""
name = os.environ.get("DECNET_TTP_TAGGER_TYPE", _DEFAULT).strip().lower()
if name == "composite":
return CompositeTagger(lifters=[])
raise ValueError(
f"Unknown tagger: {name!r}. Known: {_KNOWN}"
)
__all__ = ["get_tagger", "CompositeTagger"]