feat(ttp): E.1.11 RuleStore contract — base ABC, factory, filesystem + database stubs

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.
This commit is contained in:
2026-05-01 07:25:09 -04:00
parent b6e31e64e9
commit bcd1f14cd3
7 changed files with 434 additions and 0 deletions

View File

@@ -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",
]

149
decnet/ttp/store/base.py Normal file
View File

@@ -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.
"""

View File

@@ -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"]

View File

@@ -0,0 +1 @@
"""Rule store backend implementations — filesystem + database."""

View File

@@ -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"]

View File

@@ -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 ``<sys/inotify.h>`` (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",
]