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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
|
File without changes
|
|
@@ -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")
|