From bcd1f14cd3fed4e4d954c2d477af8b9ea9621570 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 07:25:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(ttp):=20E.1.11=20RuleStore=20contract=20?= =?UTF-8?q?=E2=80=94=20base=20ABC,=20factory,=20filesystem=20+=20database?= =?UTF-8?q?=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds decnet/ttp/store/ subpackage: - base.py: RuleState frozen dataclass, RuleChange NamedTuple, RuleStore ABC - factory.py: get_rule_store() reading DECNET_TTP_RULE_STORE_TYPE - impl/filesystem.py: FilesystemRuleStore with sys.platform=='linux' fail-fast guard, allowlist filename regex, raw inotify mask bits (lib import deferred to E.3 so contract phase compiles without the asyncinotify dep installed) - impl/database.py: DatabaseRuleStore stub (no platform guard) TTPRule + TTPRuleState SQLModels were already shipped at E.1.1; this commit closes the type-only TYPE_CHECKING forward-ref in rule_engine.py via real runtime imports through the new package. --- decnet/ttp/store/__init__.py | 25 +++++ decnet/ttp/store/base.py | 149 +++++++++++++++++++++++++++ decnet/ttp/store/factory.py | 47 +++++++++ decnet/ttp/store/impl/__init__.py | 1 + decnet/ttp/store/impl/database.py | 60 +++++++++++ decnet/ttp/store/impl/filesystem.py | 150 ++++++++++++++++++++++++++++ development/TTP_TAGGING.md | 2 + 7 files changed, 434 insertions(+) create mode 100644 decnet/ttp/store/__init__.py create mode 100644 decnet/ttp/store/base.py create mode 100644 decnet/ttp/store/factory.py create mode 100644 decnet/ttp/store/impl/__init__.py create mode 100644 decnet/ttp/store/impl/database.py create mode 100644 decnet/ttp/store/impl/filesystem.py diff --git a/decnet/ttp/store/__init__.py b/decnet/ttp/store/__init__.py new file mode 100644 index 00000000..203cd5fd --- /dev/null +++ b/decnet/ttp/store/__init__.py @@ -0,0 +1,25 @@ +"""TTP rule store — pluggable backend for rule definitions + state. + +Contract step E.1.11 of ``development/TTP_TAGGING.md``. Two backends +ship: + +* :class:`FilesystemRuleStore` — reads ``./rules/ttp/`` at projroot, + inotify-watches for hot-reload, holds operational state in-process. + Linux-only (the inotify dependency is non-portable by design). +* :class:`DatabaseRuleStore` — mirrors rule content into ``ttp_rule`` + with state in ``ttp_rule_state``; survives restart and propagates + to every worker in a swarm. + +Selection via ``DECNET_TTP_RULE_STORE_TYPE`` (default ``"filesystem"``). +""" +from __future__ import annotations + +from decnet.ttp.store.base import RuleChange, RuleState, RuleStore +from decnet.ttp.store.factory import get_rule_store + +__all__ = [ + "RuleChange", + "RuleState", + "RuleStore", + "get_rule_store", +] diff --git a/decnet/ttp/store/base.py b/decnet/ttp/store/base.py new file mode 100644 index 00000000..5a0edf5e --- /dev/null +++ b/decnet/ttp/store/base.py @@ -0,0 +1,149 @@ +"""Rule store ABC + change/state value types. + +Contract step E.1.11. The two backends (``impl/filesystem.py``, +``impl/database.py``) implement :class:`RuleStore` identically — the +E.2.14b conformance suite parametrizes over both and asserts the same +observable behavior. + +Three types live here: + +* :class:`RuleState` — operator-mutable knobs (enabled / disabled / + clipped, optional confidence ceiling, optional TTL). Frozen dataclass + so an entry sitting in an engine dispatch index cannot be torn by an + in-place mutation. +* :class:`RuleChange` — one event yielded per per-rule change by + :meth:`RuleStore.subscribe_changes`. The "incremental, never batched" + property in TTP_TAGGING.md §"Bus topics" is enforced *here*: the + store yields one change per edit, never an aggregate. +* :class:`RuleStore` — the four-method ABC: load all compiled rules, + read/write state, subscribe to changes. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, NamedTuple, Union + +from decnet.ttp.impl.rule_engine import CompiledRule + + +# ── Operational state ──────────────────────────────────────────────── + + +@dataclass(frozen=True) +class RuleState: + """Operator-controlled state stamped onto a :class:`CompiledRule`. + + Frozen so engines reading the value during an evaluate() call see + a consistent snapshot even if a parallel ``set_state()`` is in + flight. The default constructor — ``RuleState()`` — is the + "enabled, no overrides" baseline that + :meth:`RuleStore.get_state` returns for any rule that has never + had operational state set. + + Fields mirror the columns of :class:`TTPRuleState` so the + DB-backed store round-trips without translation. + """ + + state: Literal["enabled", "disabled", "clipped"] = "enabled" + #: Optional confidence ceiling. ``None`` means "use rule's base". + #: When set, the engine clamps the emitted tag's confidence + #: downward (never upward) per TTP_TAGGING.md §"Confidence model". + confidence_max: float | None = None + #: Optional TTL on the state itself. When ``expires_at`` is in the + #: past, the store returns the default enabled state and emits a + #: ``ttp.rule.state.{rule_id}`` auto-revert event. + expires_at: datetime | None = None + #: Free-form operator note (audit trail). Never PII. + reason: str | None = None + #: Operator who made the change ("filesystem" / "git" for the FS + #: store; the admin JWT subject for the DB store). + set_by: str | None = None + set_at: datetime | None = None + + +# ── Change events ──────────────────────────────────────────────────── + + +class RuleChange(NamedTuple): + """One per-rule change yielded by :meth:`RuleStore.subscribe_changes`. + + The ``change_kind`` discriminator pairs with the union type of + :attr:`new_value`: + + * ``"definition"`` → ``new_value`` is a :class:`CompiledRule` + (the YAML changed; engine atomically swaps the entry in its + dispatch index). + * ``"state"`` → ``new_value`` is a :class:`RuleState` (only + operational state changed; the engine restamps the existing + compiled rule's ``state`` field). + + The store NEVER batches: a 5-rule edit produces 5 :class:`RuleChange` + instances, not one carrying 5 entries. This is load-bearing — the + bus per-rule fan-out (``ttp.rule.reloaded.{rule_id}`` / + ``ttp.rule.state.{rule_id}``) inherits its granularity from this + iterator. + """ + + change_kind: Literal["definition", "state"] + rule_id: str + new_value: Union[CompiledRule, RuleState] + + +# ── Store ABC ──────────────────────────────────────────────────────── + + +class RuleStore(ABC): + """Pluggable backend for rule definitions + operational state. + + Implementations land at :mod:`decnet.ttp.store.impl.filesystem` + and :mod:`decnet.ttp.store.impl.database`. Both must satisfy the + E.2.14b conformance contract observably — the test suite is + parametrized over both backends and asserts identical behavior. + """ + + @abstractmethod + async def load_compiled(self) -> list[CompiledRule]: + """Return every rule this store knows about, fully compiled. + + Includes operational state stamped onto each rule's ``state`` + field (defaulting to enabled for rules without an explicit + state row). Called once at engine startup; per-rule edits + thereafter come through :meth:`subscribe_changes`. + """ + + @abstractmethod + async def get_state(self, rule_id: str) -> RuleState: + """Return the current :class:`RuleState` for *rule_id*. + + For an unknown rule_id (no state row exists) MUST return the + default ``RuleState()`` — never raise, never return ``None``. + Auto-reverts an expired state to default and emits a + ``ttp.rule.state.{rule_id}`` event before returning. + """ + + @abstractmethod + async def set_state( + self, + rule_id: str, + state: RuleState, + set_by: str, + ) -> None: + """Persist the new operational state and emit a change event. + + On a backend failure (DB write error, disk full) MUST raise — + operational state changes are NOT a tolerated-absence path. + State drift would be silent and dangerous. + """ + + @abstractmethod + def subscribe_changes(self) -> AsyncIterator[RuleChange]: + """Yield one :class:`RuleChange` per per-rule edit. + + Never batches. A 5-rule edit produces 5 yields; a 50-rule + deploy produces 50. Subscribers (the engine, bus republishers) + rely on per-rule granularity — collapsing into a batch breaks + the ``ttp.rule.reloaded.{rule_id}`` topic fan-out. + """ diff --git a/decnet/ttp/store/factory.py b/decnet/ttp/store/factory.py new file mode 100644 index 00000000..dc2477cd --- /dev/null +++ b/decnet/ttp/store/factory.py @@ -0,0 +1,47 @@ +"""Rule store factory. + +Mirrors :mod:`decnet.ttp.factory` and :mod:`decnet.intel.factory`: +callers obtain the active store via :func:`get_rule_store` rather than +instantiating a concrete class. The selected backend is whatever +``DECNET_TTP_RULE_STORE_TYPE`` resolves to (default ``"filesystem"``). + +Configuration: + +* ``DECNET_TTP_RULE_STORE_TYPE`` — ``"filesystem"`` (Linux-only) or + ``"database"`` (any platform). Unknown values raise + :class:`ValueError`. +""" +from __future__ import annotations + +import os +from typing import Final + +from decnet.ttp.store.base import RuleStore + +_KNOWN: Final[tuple[str, ...]] = ("filesystem", "database") +_DEFAULT: Final[str] = "filesystem" + + +def get_rule_store() -> RuleStore: + """Return the configured rule store instance. + + The filesystem backend imports :mod:`asyncinotify` at construction + time and refuses to run on non-Linux platforms (per TTP_TAGGING.md + §"Linux-only worker host"). macOS / Windows developers running the + test suite set ``DECNET_TTP_RULE_STORE_TYPE=database``. + """ + name = os.environ.get( + "DECNET_TTP_RULE_STORE_TYPE", _DEFAULT, + ).strip().lower() + if name == "filesystem": + from decnet.ttp.store.impl.filesystem import FilesystemRuleStore + return FilesystemRuleStore() + if name == "database": + from decnet.ttp.store.impl.database import DatabaseRuleStore + return DatabaseRuleStore() + raise ValueError( + f"Unknown rule store: {name!r}. Known: {_KNOWN}" + ) + + +__all__ = ["get_rule_store"] diff --git a/decnet/ttp/store/impl/__init__.py b/decnet/ttp/store/impl/__init__.py new file mode 100644 index 00000000..6e1d5193 --- /dev/null +++ b/decnet/ttp/store/impl/__init__.py @@ -0,0 +1 @@ +"""Rule store backend implementations — filesystem + database.""" diff --git a/decnet/ttp/store/impl/database.py b/decnet/ttp/store/impl/database.py new file mode 100644 index 00000000..0047076c --- /dev/null +++ b/decnet/ttp/store/impl/database.py @@ -0,0 +1,60 @@ +"""Database-backed rule store — ``ttp_rule`` + ``ttp_rule_state``. + +Contract step E.1.11. Bodies raise ``NotImplementedError``; the +backing tables (:class:`TTPRule`, :class:`TTPRuleState`) shipped at +E.1.1. + +Right for swarm: master syncs filesystem changes into ``ttp_rule``, +workers tail the DB, state in ``ttp_rule_state`` survives restart and +propagates to every worker. Pick via +``DECNET_TTP_RULE_STORE_TYPE=database``. + +No platform guard — works on macOS / Windows where the filesystem +backend's inotify dependency is unavailable. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator + +from decnet.ttp.impl.rule_engine import CompiledRule +from decnet.ttp.store.base import RuleChange, RuleState, RuleStore + + +class DatabaseRuleStore(RuleStore): + """``ttp_rule`` content + ``ttp_rule_state`` operational state. + + Contract phase: every method raises ``NotImplementedError``. The + impl step (E.3) implements DB-tail subscription + master-side + filesystem→DB sync. Worker-side tailing reads via the existing + repository pattern; the master's filesystem-watch sync is + structurally a delta from :class:`FilesystemRuleStore` plus a + ``ttp_rule`` upsert. + """ + + async def load_compiled(self) -> list[CompiledRule]: + raise NotImplementedError( + "DatabaseRuleStore.load_compiled lands at E.3", + ) + + async def get_state(self, rule_id: str) -> RuleState: + raise NotImplementedError( + "DatabaseRuleStore.get_state lands at E.3", + ) + + async def set_state( + self, + rule_id: str, + state: RuleState, + set_by: str, + ) -> None: + raise NotImplementedError( + "DatabaseRuleStore.set_state lands at E.3", + ) + + def subscribe_changes(self) -> AsyncIterator[RuleChange]: + raise NotImplementedError( + "DatabaseRuleStore.subscribe_changes lands at E.3", + ) + + +__all__ = ["DatabaseRuleStore"] diff --git a/decnet/ttp/store/impl/filesystem.py b/decnet/ttp/store/impl/filesystem.py new file mode 100644 index 00000000..d1a3c0bc --- /dev/null +++ b/decnet/ttp/store/impl/filesystem.py @@ -0,0 +1,150 @@ +"""Filesystem-backed rule store — reads ``./rules/ttp/`` + inotify watch. + +Contract step E.1.11. Bodies raise ``NotImplementedError``; the +constants and platform guard are real so E.2.14b conformance tests +can introspect them today. + +Linux-only. The inotify dependency (``asyncinotify`` / +``inotify_simple``) is non-portable by design; macOS / Windows +developers running the test suite use the database backend by +setting ``DECNET_TTP_RULE_STORE_TYPE=database``. The factory check in +:meth:`__init__` enforces this with a one-line operator-readable +error rather than a deep stack trace from the inotify import. + +The dependency import is **deferred** to :meth:`subscribe_changes` +during the contract phase so this module is importable without the +inotify package installed. The implementation step (E.3) moves the +import to module top per TTP_TAGGING.md §"Linux-only worker host" — +which is when the dependency is added to ``pyproject.toml``. At +contract phase the codebase compiles, mypy passes, and the constants +below are introspectable for E.2.14b tests without forcing operators +on macOS or CI machines without the lib to install it just to import +the package. +""" +from __future__ import annotations + +import re +import sys +from collections.abc import AsyncIterator +from datetime import datetime +from pathlib import Path +from typing import Final + +from decnet.ttp.impl.rule_engine import CompiledRule +from decnet.ttp.store.base import RuleChange, RuleState, RuleStore + + +# ── Filename allowlist ────────────────────────────────────────────── +# A path is accepted iff its basename FULLY matches this pattern. The +# allowlist (rather than a denylist) is deliberate per TTP_TAGGING.md +# §E.1.11: vim swap files (``.foo.yaml.swp``), atomic-save probes +# (``4913``), tilde backups (``foo.yaml~``), random tempfile +# conventions a future editor invents — all silently ignored, no +# parse, no log line. Denylists rot the moment an editor changes its +# scratch convention; the allowlist stops being clever. +_VALID_RULE_FILENAME: Final[re.Pattern[str]] = re.compile( + r"[A-Za-z0-9_]+\.ya?ml", +) + + +# ── Inotify event mask ────────────────────────────────────────────── +# Bit values from ```` (man inotify(7)). Inlined as +# raw ints so this module is importable without the inotify library +# at contract phase. The implementation step replaces these with the +# library-supplied constants on the same module-top import that lands +# the dep — same numeric value, same bitwise OR. +# +# Rationale per TTP_TAGGING.md §E.1.11 "Inotify event mask", +# verified against an actual ``strace`` of vim: +# IN_CLOSE_WRITE — vim writes in place; dominant save signal. +# IN_MOVED_TO — atomic-write editors (gedit, IDEs, deploy +# scripts) write tempfile then ``rename()``. +# IN_CREATE — brand-new rule file appears (``touch``, ``cp``). +# IN_DELETE — rule removed; engine drops it from the dispatch +# index and emits ``ttp.rule.reloaded.{rule_id}``. +_IN_CLOSE_WRITE: Final[int] = 0x00000008 +_IN_MOVED_TO: Final[int] = 0x00000080 +_IN_CREATE: Final[int] = 0x00000100 +_IN_DELETE: Final[int] = 0x00000200 + +_INOTIFY_MASK: Final[int] = ( + _IN_CLOSE_WRITE | _IN_MOVED_TO | _IN_CREATE | _IN_DELETE +) + + +# ── Watch root ────────────────────────────────────────────────────── +# Resolved relative to the project root. Tests override via a tmp_path +# fixture to avoid touching the real ``./rules/`` during the suite. +_DEFAULT_RULES_DIR: Final[Path] = Path("./rules/ttp/") + + +class FilesystemRuleStore(RuleStore): + """``./rules/ttp/`` + inotify watch + in-process state cache. + + Right for single-host dev — state lost on restart is fine when the + operator is local. Swarms use :class:`DatabaseRuleStore` so state + survives restart and propagates across worker hosts. + + Contract phase: every method raises ``NotImplementedError``. The + impl step (E.3) implements YAML parse + Pydantic validation + + inotify event loop + atomic per-rule swap into the dispatch index. + """ + + def __init__(self, rules_dir: Path | None = None) -> None: + # Fail-fast platform guard. Per TTP_TAGGING.md §E.1.11: a + # one-line operator-readable error beats a deep stack trace + # from a downstream import. + if sys.platform != "linux": + raise RuntimeError( + "FilesystemRuleStore requires Linux for inotify; use " + "DatabaseRuleStore on this platform " + "(DECNET_TTP_RULE_STORE_TYPE=database).", + ) + self._rules_dir: Path = rules_dir or _DEFAULT_RULES_DIR + # In-process state cache — lost on restart by design. The + # database backend persists across restarts; choosing this + # backend is choosing the trade-off. + self._state: dict[str, RuleState] = {} + + async def load_compiled(self) -> list[CompiledRule]: + raise NotImplementedError( + "FilesystemRuleStore.load_compiled lands at E.3", + ) + + async def get_state(self, rule_id: str) -> RuleState: + # Auto-revert expired states is impl-phase behavior; the + # in-memory dict lookup is the trivial part. Even the lookup + # belongs to E.3 so the contract surface stays uniformly + # NotImplementedError across both backends. + cached = self._state.get(rule_id) + if cached is None: + return RuleState() + if cached.expires_at is not None and cached.expires_at < datetime.now( + tz=cached.expires_at.tzinfo, + ): + # Auto-revert path — full impl (event emission, cache + # purge) lands at E.3. + return RuleState() + return cached + + async def set_state( + self, + rule_id: str, + state: RuleState, + set_by: str, + ) -> None: + raise NotImplementedError( + "FilesystemRuleStore.set_state lands at E.3", + ) + + def subscribe_changes(self) -> AsyncIterator[RuleChange]: + raise NotImplementedError( + "FilesystemRuleStore.subscribe_changes lands at E.3", + ) + + +__all__ = [ + "FilesystemRuleStore", + "_INOTIFY_MASK", + "_VALID_RULE_FILENAME", +] diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 2570f460..cb10d539 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -2374,6 +2374,8 @@ unrelated events. **E.1.11 — RuleStore contract** (`decnet/ttp/store/{base.py, factory.py, impl/}`) +**Status:** ✅ done. + - `class RuleState` frozen dataclass: state literal ("enabled" | "disabled" | "clipped"), `confidence_max`, `expires_at`, `reason`, `set_by`, `set_at`. Default constructor