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:
25
decnet/ttp/store/__init__.py
Normal file
25
decnet/ttp/store/__init__.py
Normal 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
149
decnet/ttp/store/base.py
Normal 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.
|
||||
"""
|
||||
47
decnet/ttp/store/factory.py
Normal file
47
decnet/ttp/store/factory.py
Normal 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"]
|
||||
1
decnet/ttp/store/impl/__init__.py
Normal file
1
decnet/ttp/store/impl/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Rule store backend implementations — filesystem + database."""
|
||||
60
decnet/ttp/store/impl/database.py
Normal file
60
decnet/ttp/store/impl/database.py
Normal 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"]
|
||||
150
decnet/ttp/store/impl/filesystem.py
Normal file
150
decnet/ttp/store/impl/filesystem.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user