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