alter-runtime 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,81 @@
1
+ """Compost adapter (Eco-9) — EventBusSubscriber over MQTT, plate-tag filtered.
2
+
3
+ Subscribes to ``tapo/temp_humid/+/state`` events on the in-process bus
4
+ (republished by the household-bridge from Home-Assistant / Mosquitto).
5
+ A device only participates in compost-trait derivation if its member-
6
+ attested plate tag equals ``"compost"`` — declared at pairing time and
7
+ read from the payload (``plate_tag`` / ``tag`` field).
8
+
9
+ Per spec §5 raw temperature traces and pile location never leave the
10
+ daemon; only the banded trait emits upward.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from alter_runtime.adapters.household._base import EventBusSubscriberBase
18
+ from alter_runtime.adapters.household.compost.storage import CompostStorage
19
+ from alter_runtime.adapters.household.compost.traits import CompostTraits
20
+ from alter_runtime.config import DaemonConfig
21
+ from alter_runtime.subscribers.bus import EventBus
22
+
23
+ __all__ = ["CompostAdapter", "TAPO_TEMP_HUMID_TOPIC", "COMPOST_PLATE_TAG"]
24
+
25
+ #: MQTT topic glob bridged onto the in-process bus.
26
+ TAPO_TEMP_HUMID_TOPIC: str = "tapo/temp_humid/+/state"
27
+
28
+ #: Member-attested plate tag that opts a sensor into compost-trait derivation.
29
+ COMPOST_PLATE_TAG: str = "compost"
30
+
31
+
32
+ class CompostAdapter(EventBusSubscriberBase):
33
+ """Compost (Eco-9) adapter."""
34
+
35
+ name: str = "compost"
36
+ subscribe_topics: tuple[str, ...] = (TAPO_TEMP_HUMID_TOPIC,)
37
+
38
+ def __init__(self, config: DaemonConfig, bus: EventBus) -> None:
39
+ super().__init__(config, bus)
40
+ self._storage = CompostStorage()
41
+ self._traits = CompostTraits()
42
+
43
+ async def handle_event(self, payload: Any) -> None:
44
+ """Filter on plate tag then persist a pile-core temperature sample.
45
+
46
+ Expected payload shape (best-effort)::
47
+
48
+ {
49
+ "device_id": "AA:BB:...",
50
+ "plate_tag": "compost",
51
+ "temp_c": 58.4,
52
+ "humidity_pct": 62.0,
53
+ "ts": "2026-..."
54
+ }
55
+ """
56
+ if not isinstance(payload, dict):
57
+ self._logger.debug("compost ignoring non-dict payload")
58
+ return
59
+ plate_tag = (payload.get("plate_tag") or payload.get("tag") or "").lower()
60
+ if plate_tag != COMPOST_PLATE_TAG:
61
+ # Spec §5 — only compost-tagged sensors feed the trait.
62
+ return
63
+ device_id = payload.get("device_id") or payload.get("mac")
64
+ temp_c = payload.get("temp_c") or payload.get("temperature_c")
65
+ ts = payload.get("ts") or payload.get("timestamp")
66
+ if not device_id or temp_c is None:
67
+ self._logger.debug(
68
+ "compost dropping incomplete tagged event keys=%s",
69
+ list(payload.keys()),
70
+ )
71
+ return
72
+
73
+ # TODO(eco-9): detect thermophilic excursions (≥55 °C ramps following
74
+ # declared feed/turn events); cluster into seasonal cycles.
75
+ # TODO(eco-9): derive `soil_cycle_patience_band` from rolling 365-day
76
+ # cycle-completion ledger; gate on ≥0.6 sensor uptime over 90 days.
77
+ # TODO(eco-9): jitter timestamps to ±15 min before any upward emit
78
+ # (spec §3 — sub-hour granularity discarded).
79
+ # TODO(eco-9): emit trait band via self._traits once a cycle window
80
+ # closes; current scaffold persists samples and stops.
81
+ await self._storage.record_sample(device_id=str(device_id), temp_c=float(temp_c), ts=ts)
@@ -0,0 +1,75 @@
1
+ """Compost storage layer — local SQLite, mode 600."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from alter_runtime.config import data_dir
12
+
13
+ __all__ = ["CompostStorage", "DB_FILENAME"]
14
+
15
+ logger = logging.getLogger("alter_runtime.adapters.household.compost.storage")
16
+
17
+ DB_FILENAME: str = "compost.db"
18
+
19
+ _SCHEMA: str = """
20
+ CREATE TABLE IF NOT EXISTS pile_core_samples (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ device_id TEXT NOT NULL,
23
+ temp_c REAL NOT NULL,
24
+ ts TEXT,
25
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_pile_samples_device
28
+ ON pile_core_samples (device_id);
29
+ """
30
+
31
+
32
+ class CompostStorage:
33
+ """Async wrapper around a sqlite3 store for compost pile-core samples."""
34
+
35
+ def __init__(self, db_path: Path | None = None) -> None:
36
+ self._db_path = db_path or (data_dir() / DB_FILENAME)
37
+ self._lock = asyncio.Lock()
38
+ self._ensure_db()
39
+
40
+ @property
41
+ def db_path(self) -> Path:
42
+ return self._db_path
43
+
44
+ def _ensure_db(self) -> None:
45
+ self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
46
+ if not self._db_path.exists():
47
+ self._db_path.touch(mode=0o600, exist_ok=True)
48
+ else:
49
+ try:
50
+ self._db_path.chmod(0o600)
51
+ except OSError as exc: # pragma: no cover
52
+ logger.warning("compost chmod 0o600 failed: %s", exc)
53
+ with sqlite3.connect(self._db_path) as conn:
54
+ conn.executescript(_SCHEMA)
55
+
56
+ async def record_sample(self, *, device_id: str, temp_c: float, ts: Any | None = None) -> None:
57
+ async with self._lock:
58
+ await asyncio.to_thread(self._insert_sample, device_id, temp_c, ts)
59
+
60
+ def _insert_sample(self, device_id: str, temp_c: float, ts: Any | None) -> None:
61
+ with sqlite3.connect(self._db_path) as conn:
62
+ conn.execute(
63
+ "INSERT INTO pile_core_samples (device_id, temp_c, ts) VALUES (?, ?, ?)",
64
+ (device_id, temp_c, str(ts) if ts is not None else None),
65
+ )
66
+ conn.commit()
67
+
68
+ async def sample_count(self) -> int:
69
+ return await asyncio.to_thread(self._sample_count_sync)
70
+
71
+ def _sample_count_sync(self) -> int:
72
+ with sqlite3.connect(self._db_path) as conn:
73
+ cur = conn.execute("SELECT COUNT(*) FROM pile_core_samples")
74
+ row = cur.fetchone()
75
+ return int(row[0]) if row else 0
@@ -0,0 +1,62 @@
1
+ """Tests for CompostAdapter — plate-tag filter + sample persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from alter_runtime.adapters.household.compost.adapter import (
10
+ COMPOST_PLATE_TAG,
11
+ TAPO_TEMP_HUMID_TOPIC,
12
+ CompostAdapter,
13
+ )
14
+ from alter_runtime.adapters.household.compost.storage import CompostStorage
15
+ from alter_runtime.config import DaemonConfig
16
+ from alter_runtime.subscribers.bus import EventBus
17
+
18
+
19
+ def test_constants_match_spec() -> None:
20
+ assert TAPO_TEMP_HUMID_TOPIC == "tapo/temp_humid/+/state"
21
+ assert COMPOST_PLATE_TAG == "compost"
22
+ assert TAPO_TEMP_HUMID_TOPIC in CompostAdapter.subscribe_topics
23
+
24
+
25
+ async def test_adapter_persists_compost_tagged_event(
26
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
27
+ ) -> None:
28
+ db_path = tmp_path / "compost.db"
29
+ monkeypatch.setattr(
30
+ "alter_runtime.adapters.household.compost.adapter.CompostStorage",
31
+ lambda: CompostStorage(db_path=db_path),
32
+ )
33
+ bus = EventBus()
34
+ adapter = CompostAdapter(config=DaemonConfig(), bus=bus)
35
+ await adapter.handle_event(
36
+ {
37
+ "device_id": "AA:BB:CC:DD:EE:11",
38
+ "plate_tag": "compost",
39
+ "temp_c": 58.4,
40
+ "ts": "2026-05-27T00:00:00Z",
41
+ }
42
+ )
43
+ assert await CompostStorage(db_path=db_path).sample_count() == 1
44
+
45
+
46
+ async def test_adapter_drops_non_compost_tagged_event(
47
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
48
+ ) -> None:
49
+ db_path = tmp_path / "compost.db"
50
+ monkeypatch.setattr(
51
+ "alter_runtime.adapters.household.compost.adapter.CompostStorage",
52
+ lambda: CompostStorage(db_path=db_path),
53
+ )
54
+ bus = EventBus()
55
+ adapter = CompostAdapter(config=DaemonConfig(), bus=bus)
56
+ # Tagged "bedroom" — must NOT persist, must NOT raise.
57
+ await adapter.handle_event(
58
+ {"device_id": "AA:BB:CC:DD:EE:22", "plate_tag": "bedroom", "temp_c": 20.0}
59
+ )
60
+ # No tag at all — also dropped.
61
+ await adapter.handle_event({"device_id": "X", "temp_c": 20.0})
62
+ assert await CompostStorage(db_path=db_path).sample_count() == 0
@@ -0,0 +1,23 @@
1
+ """Tests for compost SQLite storage — mode 600 + sample insert."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from pathlib import Path
7
+
8
+ from alter_runtime.adapters.household.compost.storage import CompostStorage
9
+
10
+
11
+ async def test_db_file_mode_is_600(tmp_path: Path) -> None:
12
+ db_path = tmp_path / "compost.db"
13
+ CompostStorage(db_path=db_path)
14
+ mode = stat.S_IMODE(db_path.stat().st_mode)
15
+ assert mode == 0o600, f"expected mode 0o600, got {oct(mode)}"
16
+
17
+
18
+ async def test_record_sample_persists_row(tmp_path: Path) -> None:
19
+ db_path = tmp_path / "compost.db"
20
+ storage = CompostStorage(db_path=db_path)
21
+ assert await storage.sample_count() == 0
22
+ await storage.record_sample(device_id="AA:BB:CC:DD:EE:01", temp_c=58.4, ts=None)
23
+ assert await storage.sample_count() == 1
@@ -0,0 +1,38 @@
1
+ """Tests for compost trait emitter — Clause-4 banlist + band shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from alter_runtime.adapters.household.compost.traits import (
8
+ CLAUSE_4_BANLIST,
9
+ BannedTraitKindError,
10
+ CompostTraits,
11
+ )
12
+
13
+
14
+ def test_banlist_contains_all_spec_kinds() -> None:
15
+ assert {"compost-failure-shame", "anaerobic-collapse-anxiety"}.issubset(CLAUSE_4_BANLIST)
16
+
17
+
18
+ @pytest.mark.parametrize("banned_kind", sorted(CLAUSE_4_BANLIST))
19
+ def test_banned_kinds_are_refused(banned_kind: str) -> None:
20
+ traits = CompostTraits()
21
+ with pytest.raises(BannedTraitKindError):
22
+ traits.emit(banned_kind, band="attuned")
23
+
24
+
25
+ def test_soil_cycle_patience_band_emits() -> None:
26
+ traits = CompostTraits()
27
+ emission = traits.emit_soil_cycle_patience_band("90-365d")
28
+ assert emission.kind == "soil_cycle_patience_band"
29
+ assert emission.band == "90-365d"
30
+ assert emission.provenance == "passive_local_sensor"
31
+ assert emission.stream == "compost"
32
+
33
+
34
+ def test_long_horizon_stewardship_emits() -> None:
35
+ traits = CompostTraits()
36
+ emission = traits.emit_long_horizon_stewardship("deeply-attuned")
37
+ assert emission.kind == "long_horizon_stewardship"
38
+ assert emission.band == "deeply-attuned"
@@ -0,0 +1,79 @@
1
+ """Compost trait band emitters (Eco-9) with Clause-4 banlist enforcement.
2
+
3
+ Trait floor (spec §4):
4
+
5
+ * ``compost_steward_active`` (boolean band)
6
+ * ``soil_cycle_patience_band`` (``{< 30d, 30-90d, 90-365d, > 365d-multi-cycle}``)
7
+ * ``long_horizon_stewardship`` (``{absent, emerging, attuned, deeply-attuned}``)
8
+
9
+ Clause-4 banlist (spec §11):
10
+
11
+ * ``compost-failure-shame``
12
+ * ``anaerobic-collapse-anxiety``
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Literal
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ __all__ = [
23
+ "CLAUSE_4_BANLIST",
24
+ "BannedTraitKindError",
25
+ "CompostTraitEmission",
26
+ "CompostTraits",
27
+ "LongHorizonBand",
28
+ "SoilCyclePatienceBand",
29
+ ]
30
+
31
+ logger = logging.getLogger("alter_runtime.adapters.household.compost.traits")
32
+
33
+ CLAUSE_4_BANLIST: frozenset[str] = frozenset(
34
+ {
35
+ "compost-failure-shame",
36
+ "anaerobic-collapse-anxiety",
37
+ }
38
+ )
39
+
40
+ SoilCyclePatienceBand = Literal["<30d", "30-90d", "90-365d", ">365d-multi-cycle"]
41
+ LongHorizonBand = Literal["absent", "emerging", "attuned", "deeply-attuned"]
42
+
43
+
44
+ class BannedTraitKindError(RuntimeError):
45
+ """Raised when a caller requests a Clause-4-banned trait kind."""
46
+
47
+
48
+ class CompostTraitEmission(BaseModel):
49
+ kind: str = Field(description="Trait kind, e.g. 'soil_cycle_patience_band'")
50
+ band: str = Field(description="The banded label (never numeric)")
51
+ provenance: str = Field(default="passive_local_sensor")
52
+ stream: str = Field(default="compost")
53
+
54
+
55
+ class CompostTraits:
56
+ """Compost trait emitter with Clause-4 banlist enforcement."""
57
+
58
+ def emit(self, kind: str, band: str) -> CompostTraitEmission:
59
+ self._refuse_if_banned(kind)
60
+ # TODO(clause-3-identity-income): return/x402 hook attaches here per
61
+ # D-PROV-1 + clause-3 ratification. Each emission upward is the
62
+ # point at which Identity-Income return-flow is metered. Hook
63
+ # implementation lives outside this scaffold pass.
64
+ return CompostTraitEmission(kind=kind, band=band)
65
+
66
+ def emit_soil_cycle_patience_band(self, band: SoilCyclePatienceBand) -> CompostTraitEmission:
67
+ return self.emit("soil_cycle_patience_band", band)
68
+
69
+ def emit_long_horizon_stewardship(self, band: LongHorizonBand) -> CompostTraitEmission:
70
+ return self.emit("long_horizon_stewardship", band)
71
+
72
+ def emit_compost_steward_active(self, active: bool) -> CompostTraitEmission:
73
+ return self.emit("compost_steward_active", "active" if active else "inactive")
74
+
75
+ @staticmethod
76
+ def _refuse_if_banned(kind: str) -> None:
77
+ if kind in CLAUSE_4_BANLIST:
78
+ logger.warning("compost refusing Clause-4-banned trait kind=%s", kind)
79
+ raise BannedTraitKindError(f"trait kind {kind!r} is Clause-4 banned for compost")
@@ -0,0 +1,30 @@
1
+ """Self-hoster digital-stewardship adapter (Eco-15).
2
+
3
+ Polls OWN host every 6 hours (degenerate-LAN; reads its own filesystem
4
+ + systemd D-Bus). Five banded sub-traits: ``os_update_discipline``,
5
+ ``backup_cadence``, ``self_hosted_service_breadth``, ``tls_hygiene``,
6
+ ``uptime_steward``.
7
+
8
+ Member-authored allowlists at pairing (services / backup-repo paths /
9
+ LE cert dirs) — those choices stay LOCAL, never surfaced upward.
10
+
11
+ Spec: ``phase2-wave2-stub-specs-pack-2026-05-27.md`` §6.
12
+ """
13
+
14
+ from alter_runtime.adapters.household.self_hoster.adapter import (
15
+ AllowlistViolationError,
16
+ SelfHosterAdapter,
17
+ SelfHosterAllowlists,
18
+ )
19
+ from alter_runtime.adapters.household.self_hoster.traits import (
20
+ CLAUSE_4_BANLIST,
21
+ SelfHosterTraits,
22
+ )
23
+
24
+ __all__ = [
25
+ "AllowlistViolationError",
26
+ "CLAUSE_4_BANLIST",
27
+ "SelfHosterAdapter",
28
+ "SelfHosterAllowlists",
29
+ "SelfHosterTraits",
30
+ ]
@@ -0,0 +1,248 @@
1
+ """Self-hoster adapter (Eco-15) — PassiveLanPoller, own-host only.
2
+
3
+ Polls the LOCAL host every 6 hours. Reads:
4
+
5
+ * systemd D-Bus ``org.freedesktop.systemd1`` for unit list + uptime
6
+ * package-manager metadata (``pacman`` / ``apt`` / ``opkg``) for last
7
+ update time
8
+ * backup snapshot directories (restic / borg / kopia) for last-snapshot
9
+ mtime
10
+ * Let's Encrypt cert dirs (``/etc/letsencrypt/live/*/cert.pem``) for
11
+ renewal compliance
12
+ * OpenWrt ``/usr/lib/opkg/info`` when running on OpenWrt
13
+
14
+ All five inputs are gated on member-authored allowlists declared at
15
+ pairing time (services / backup repos / cert dirs). The adapter never
16
+ walks the filesystem outside those allowlists.
17
+
18
+ V&V hardening pass (Phase-2 substrate-adapter pilot, 2026-05-28): the
19
+ observer-execution surface — anywhere this module would invoke
20
+ subprocess / systemctl / Path.stat / Path.iterdir against a host target
21
+ — MUST first pass the target through :meth:`SelfHosterAdapter._invoke_observer`,
22
+ which consults :class:`SelfHosterAllowlists` and refuses any
23
+ out-of-allowlist target with :class:`AllowlistViolationError`. Real
24
+ read implementations land in follow-up passes; the guard is wired now
25
+ so the surface is structurally refused at scaffold time.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Awaitable, Callable, TypeVar
33
+
34
+ from alter_runtime.adapters.household._base import PassiveLanPollerBase
35
+ from alter_runtime.adapters.household.self_hoster.storage import SelfHosterStorage
36
+ from alter_runtime.adapters.household.self_hoster.traits import SelfHosterTraits
37
+ from alter_runtime.config import DaemonConfig
38
+
39
+ __all__ = [
40
+ "AllowlistBucket",
41
+ "AllowlistViolationError",
42
+ "SelfHosterAdapter",
43
+ "SelfHosterAllowlists",
44
+ ]
45
+
46
+ #: Default 6-hour cadence per stub spec §6.
47
+ DEFAULT_POLL_INTERVAL_SECONDS: float = 6 * 60 * 60
48
+
49
+ #: Names of the buckets on :class:`SelfHosterAllowlists` the
50
+ #: :meth:`SelfHosterAdapter._check_allowlist` guard accepts. Using a
51
+ #: ``Literal``-style sentinel rather than a free string so a typo in the
52
+ #: bucket name surfaces at code-read time, not at silent-skip runtime.
53
+ AllowlistBucket = str # one of "systemd_units" / "backup_repo_paths" / "letsencrypt_cert_dirs"
54
+
55
+ _T = TypeVar("_T")
56
+
57
+
58
+ class AllowlistViolationError(RuntimeError):
59
+ """Raised when an observer call site targets an out-of-allowlist resource.
60
+
61
+ The :class:`SelfHosterAllowlists` declared at pairing time is the
62
+ only legitimate set of host resources the daemon may read. Any
63
+ subprocess / systemctl / filesystem-walk call site that would touch
64
+ something outside those lists is refused at the
65
+ :meth:`SelfHosterAdapter._check_allowlist` guard before the
66
+ invocation happens — the daemon never silently reads outside the
67
+ member-authored allowlist.
68
+ """
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class SelfHosterAllowlists:
73
+ """Member-authored allowlists declared at pairing time.
74
+
75
+ The adapter never reads anything outside these lists. Empty lists
76
+ cause the corresponding sub-trait to skip its computation rather
77
+ than scan the whole host.
78
+ """
79
+
80
+ systemd_units: tuple[str, ...] = ()
81
+ backup_repo_paths: tuple[Path, ...] = ()
82
+ letsencrypt_cert_dirs: tuple[Path, ...] = ()
83
+
84
+
85
+ class SelfHosterAdapter(PassiveLanPollerBase):
86
+ """Self-hoster (Eco-15) own-host poller."""
87
+
88
+ name: str = "self_hoster"
89
+ poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS
90
+
91
+ def __init__(
92
+ self,
93
+ config: DaemonConfig,
94
+ allowlists: SelfHosterAllowlists | None = None,
95
+ ) -> None:
96
+ super().__init__(config)
97
+ self._allowlists = allowlists or SelfHosterAllowlists()
98
+ self._storage = SelfHosterStorage()
99
+ self._traits = SelfHosterTraits()
100
+
101
+ @property
102
+ def allowlists(self) -> SelfHosterAllowlists:
103
+ return self._allowlists
104
+
105
+ async def poll_once(self) -> None:
106
+ """Run one observation cycle across the five sub-trait inputs."""
107
+ await self._observe_os_update_discipline()
108
+ await self._observe_backup_cadence()
109
+ await self._observe_service_breadth()
110
+ await self._observe_tls_hygiene()
111
+ await self._observe_uptime_steward()
112
+
113
+ # ------------------------------------------------------------------
114
+ # Observer-execution guard (V&V hardening, 2026-05-28)
115
+ # ------------------------------------------------------------------
116
+
117
+ def _check_allowlist(self, *, kind: str, target: object, bucket: AllowlistBucket) -> None:
118
+ """Refuse any observer invocation whose target is out-of-allowlist.
119
+
120
+ Every call site in this module that would invoke ``subprocess`` /
121
+ ``systemctl`` / ``Path.stat`` / ``Path.iterdir`` against a host
122
+ target MUST call this guard first. The guard consults the
123
+ bucket on :class:`SelfHosterAllowlists` named by ``bucket`` and
124
+ raises :class:`AllowlistViolationError` if ``target`` is not in
125
+ that bucket.
126
+
127
+ Parameters
128
+ ----------
129
+ kind:
130
+ The sub-trait kind being observed (``"backup_cadence"`` etc.).
131
+ Used in the warning log + exception message for traceability.
132
+ target:
133
+ The concrete host resource — a systemd unit name, a backup-repo
134
+ ``Path``, a letsencrypt cert dir ``Path``.
135
+ bucket:
136
+ One of ``"systemd_units"``, ``"backup_repo_paths"``,
137
+ ``"letsencrypt_cert_dirs"`` — the attribute name on
138
+ :class:`SelfHosterAllowlists` to consult.
139
+
140
+ Raises
141
+ ------
142
+ AllowlistViolationError
143
+ If ``target`` is not in ``self._allowlists.<bucket>``.
144
+ AttributeError
145
+ If ``bucket`` does not name a known attribute — surfaces a
146
+ typo loudly rather than silently passing.
147
+ """
148
+ allowed = getattr(self._allowlists, bucket)
149
+ if target not in allowed:
150
+ self._logger.warning(
151
+ "self_hoster refusing out-of-allowlist invocation kind=%s target=%s bucket=%s",
152
+ kind,
153
+ target,
154
+ bucket,
155
+ )
156
+ raise AllowlistViolationError(
157
+ f"self_hoster refused observer invocation kind={kind!r} "
158
+ f"target={target!r} bucket={bucket!r} — target not in "
159
+ f"member-authored allowlist"
160
+ )
161
+
162
+ async def _invoke_observer(
163
+ self,
164
+ *,
165
+ kind: str,
166
+ target: object,
167
+ bucket: AllowlistBucket,
168
+ invocation: Callable[[], Awaitable[_T]],
169
+ ) -> _T:
170
+ """Run a guarded observer invocation.
171
+
172
+ Every future ``subprocess`` / ``systemctl`` / filesystem-walk
173
+ call site goes through this wrapper so the allowlist guard is
174
+ impossible to bypass at call time. Pattern::
175
+
176
+ await self._invoke_observer(
177
+ kind="backup_cadence",
178
+ target=path,
179
+ bucket="backup_repo_paths",
180
+ invocation=lambda: asyncio.to_thread(path.stat),
181
+ )
182
+
183
+ ``invocation`` is only awaited if :meth:`_check_allowlist`
184
+ accepts the target.
185
+ """
186
+ self._check_allowlist(kind=kind, target=target, bucket=bucket)
187
+ return await invocation()
188
+
189
+ # ------------------------------------------------------------------
190
+ # Per-sub-trait observers — scaffold-level, no real reads yet
191
+ # ------------------------------------------------------------------
192
+
193
+ async def _observe_os_update_discipline(self) -> None:
194
+ # TODO(eco-15): shell-out to pacman -Q --info / apt list --installed /
195
+ # opkg list-installed; capture last-update-time per package mgr;
196
+ # never expose package list upward, only band.
197
+ # When wired, route the shell-out through self._invoke_observer with
198
+ # a dedicated allowlist bucket (package-mgr metadata is not bound to
199
+ # the existing three buckets — extend SelfHosterAllowlists first).
200
+ await self._storage.record_observation(
201
+ kind="os_update_discipline", value_summary="unobserved"
202
+ )
203
+
204
+ async def _observe_backup_cadence(self) -> None:
205
+ # TODO(eco-15): for each path in allowlists.backup_repo_paths, stat()
206
+ # the snapshot directories and record latest mtime; derive 90-day
207
+ # cadence band downstream.
208
+ for path in self._allowlists.backup_repo_paths:
209
+ # Allowlist guard exercised even at scaffold time — the iteration
210
+ # is over the allowlist itself, so the path is trivially valid.
211
+ # The check is here so the call shape mirrors the eventual real
212
+ # stat()-bearing invocation.
213
+ self._check_allowlist(kind="backup_cadence", target=path, bucket="backup_repo_paths")
214
+ self._logger.debug("self_hoster backup-repo allowlisted path=%s", path)
215
+ await self._storage.record_observation(kind="backup_cadence", value_summary="unobserved")
216
+
217
+ async def _observe_service_breadth(self) -> None:
218
+ # TODO(eco-15): connect to systemd D-Bus org.freedesktop.systemd1,
219
+ # filter ListUnits to allowlists.systemd_units, count active.
220
+ # TODO(eco-15): dbus-next is an optional extra (pyproject [dbus]);
221
+ # degrade gracefully when not installed.
222
+ for unit in self._allowlists.systemd_units:
223
+ self._check_allowlist(
224
+ kind="self_hosted_service_breadth",
225
+ target=unit,
226
+ bucket="systemd_units",
227
+ )
228
+ self._logger.debug("self_hoster systemd-unit allowlisted unit=%s", unit)
229
+ await self._storage.record_observation(
230
+ kind="self_hosted_service_breadth", value_summary="unobserved"
231
+ )
232
+
233
+ async def _observe_tls_hygiene(self) -> None:
234
+ # TODO(eco-15): for each path in allowlists.letsencrypt_cert_dirs,
235
+ # parse cert.pem expiry via cryptography (already a dep) and
236
+ # derive renewal-compliance band. Never read private keys.
237
+ for path in self._allowlists.letsencrypt_cert_dirs:
238
+ self._check_allowlist(kind="tls_hygiene", target=path, bucket="letsencrypt_cert_dirs")
239
+ self._logger.debug("self_hoster LE-cert allowlisted dir=%s", path)
240
+ await self._storage.record_observation(kind="tls_hygiene", value_summary="unobserved")
241
+
242
+ async def _observe_uptime_steward(self) -> None:
243
+ # TODO(eco-15): derive 30-day uptime ratio from systemd boot-list +
244
+ # monotonic clock; gate against allowlisted-service availability.
245
+ # Per-unit reads MUST go through self._invoke_observer with bucket
246
+ # "systemd_units" — uptime is computed against allowlisted services
247
+ # only, never the full unit list.
248
+ await self._storage.record_observation(kind="uptime_steward", value_summary="unobserved")