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,83 @@
|
|
|
1
|
+
"""Self-hoster storage layer — local SQLite, mode 600.
|
|
2
|
+
|
|
3
|
+
Persists per-sub-trait observation rows. Schema is deliberately
|
|
4
|
+
coarse: ``kind`` + a short summary string + timestamp. Detailed
|
|
5
|
+
per-source values (package lists, snapshot mtimes, cert expiries) stay
|
|
6
|
+
inside the adapter's poll cycle and never reach the storage layer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from alter_runtime.config import data_dir
|
|
17
|
+
|
|
18
|
+
__all__ = ["SelfHosterStorage", "DB_FILENAME"]
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("alter_runtime.adapters.household.self_hoster.storage")
|
|
21
|
+
|
|
22
|
+
DB_FILENAME: str = "self_hoster.db"
|
|
23
|
+
|
|
24
|
+
_SCHEMA: str = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS subtrait_observations (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
kind TEXT NOT NULL,
|
|
28
|
+
value_summary TEXT NOT NULL,
|
|
29
|
+
observed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_subtrait_kind
|
|
32
|
+
ON subtrait_observations (kind);
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SelfHosterStorage:
|
|
37
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
38
|
+
self._db_path = db_path or (data_dir() / DB_FILENAME)
|
|
39
|
+
self._lock = asyncio.Lock()
|
|
40
|
+
self._ensure_db()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def db_path(self) -> Path:
|
|
44
|
+
return self._db_path
|
|
45
|
+
|
|
46
|
+
def _ensure_db(self) -> None:
|
|
47
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
48
|
+
if not self._db_path.exists():
|
|
49
|
+
self._db_path.touch(mode=0o600, exist_ok=True)
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
self._db_path.chmod(0o600)
|
|
53
|
+
except OSError as exc: # pragma: no cover
|
|
54
|
+
logger.warning("self_hoster chmod 0o600 failed: %s", exc)
|
|
55
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
56
|
+
conn.executescript(_SCHEMA)
|
|
57
|
+
|
|
58
|
+
async def record_observation(self, *, kind: str, value_summary: str) -> None:
|
|
59
|
+
async with self._lock:
|
|
60
|
+
await asyncio.to_thread(self._insert_observation, kind, value_summary)
|
|
61
|
+
|
|
62
|
+
def _insert_observation(self, kind: str, value_summary: str) -> None:
|
|
63
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
64
|
+
conn.execute(
|
|
65
|
+
"INSERT INTO subtrait_observations (kind, value_summary) VALUES (?, ?)",
|
|
66
|
+
(kind, value_summary),
|
|
67
|
+
)
|
|
68
|
+
conn.commit()
|
|
69
|
+
|
|
70
|
+
async def observation_count(self, *, kind: str | None = None) -> int:
|
|
71
|
+
return await asyncio.to_thread(self._observation_count_sync, kind)
|
|
72
|
+
|
|
73
|
+
def _observation_count_sync(self, kind: str | None) -> int:
|
|
74
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
75
|
+
if kind is None:
|
|
76
|
+
cur = conn.execute("SELECT COUNT(*) FROM subtrait_observations")
|
|
77
|
+
else:
|
|
78
|
+
cur = conn.execute(
|
|
79
|
+
"SELECT COUNT(*) FROM subtrait_observations WHERE kind = ?",
|
|
80
|
+
(kind,),
|
|
81
|
+
)
|
|
82
|
+
row = cur.fetchone()
|
|
83
|
+
return int(row[0]) if row else 0
|
|
File without changes
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Tests for SelfHosterAdapter — poll_once invokes all five sub-observers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from alter_runtime.adapters.household.self_hoster.adapter import (
|
|
10
|
+
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
11
|
+
AllowlistViolationError,
|
|
12
|
+
SelfHosterAdapter,
|
|
13
|
+
SelfHosterAllowlists,
|
|
14
|
+
)
|
|
15
|
+
from alter_runtime.adapters.household.self_hoster.storage import SelfHosterStorage
|
|
16
|
+
from alter_runtime.config import DaemonConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_default_poll_interval_is_6_hours() -> None:
|
|
20
|
+
assert DEFAULT_POLL_INTERVAL_SECONDS == 6 * 60 * 60
|
|
21
|
+
assert SelfHosterAdapter.poll_interval_seconds == 6 * 60 * 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_allowlists_default_to_empty_tuples() -> None:
|
|
25
|
+
allowlists = SelfHosterAllowlists()
|
|
26
|
+
assert allowlists.systemd_units == ()
|
|
27
|
+
assert allowlists.backup_repo_paths == ()
|
|
28
|
+
assert allowlists.letsencrypt_cert_dirs == ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def test_poll_once_records_one_row_per_subtrait(
|
|
32
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
33
|
+
) -> None:
|
|
34
|
+
db_path = tmp_path / "self_hoster.db"
|
|
35
|
+
monkeypatch.setattr(
|
|
36
|
+
"alter_runtime.adapters.household.self_hoster.adapter.SelfHosterStorage",
|
|
37
|
+
lambda: SelfHosterStorage(db_path=db_path),
|
|
38
|
+
)
|
|
39
|
+
adapter = SelfHosterAdapter(
|
|
40
|
+
config=DaemonConfig(),
|
|
41
|
+
allowlists=SelfHosterAllowlists(
|
|
42
|
+
systemd_units=("nginx.service",),
|
|
43
|
+
backup_repo_paths=(Path("/tmp/restic-fake"),),
|
|
44
|
+
letsencrypt_cert_dirs=(Path("/tmp/letsencrypt-fake/live/example"),),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
await adapter.poll_once()
|
|
48
|
+
storage = SelfHosterStorage(db_path=db_path)
|
|
49
|
+
# Five sub-trait observations recorded in one poll cycle.
|
|
50
|
+
assert await storage.observation_count() == 5
|
|
51
|
+
for kind in (
|
|
52
|
+
"os_update_discipline",
|
|
53
|
+
"backup_cadence",
|
|
54
|
+
"self_hosted_service_breadth",
|
|
55
|
+
"tls_hygiene",
|
|
56
|
+
"uptime_steward",
|
|
57
|
+
):
|
|
58
|
+
assert await storage.observation_count(kind=kind) == 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ----------------------------------------------------------------------
|
|
62
|
+
# V&V hardening (2026-05-28): observer-execution allowlist guard tests
|
|
63
|
+
# ----------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _adapter_with(
|
|
67
|
+
*,
|
|
68
|
+
systemd_units: tuple[str, ...] = (),
|
|
69
|
+
backup_repo_paths: tuple[Path, ...] = (),
|
|
70
|
+
letsencrypt_cert_dirs: tuple[Path, ...] = (),
|
|
71
|
+
tmp_path: Path,
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
73
|
+
) -> SelfHosterAdapter:
|
|
74
|
+
db_path = tmp_path / "self_hoster.db"
|
|
75
|
+
monkeypatch.setattr(
|
|
76
|
+
"alter_runtime.adapters.household.self_hoster.adapter.SelfHosterStorage",
|
|
77
|
+
lambda: SelfHosterStorage(db_path=db_path),
|
|
78
|
+
)
|
|
79
|
+
return SelfHosterAdapter(
|
|
80
|
+
config=DaemonConfig(),
|
|
81
|
+
allowlists=SelfHosterAllowlists(
|
|
82
|
+
systemd_units=systemd_units,
|
|
83
|
+
backup_repo_paths=backup_repo_paths,
|
|
84
|
+
letsencrypt_cert_dirs=letsencrypt_cert_dirs,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_observer_refuses_out_of_allowlist_systemd_unit(
|
|
90
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
91
|
+
) -> None:
|
|
92
|
+
"""A systemd-unit target not in allowlists.systemd_units MUST be refused."""
|
|
93
|
+
adapter = _adapter_with(
|
|
94
|
+
systemd_units=("nginx.service",),
|
|
95
|
+
tmp_path=tmp_path,
|
|
96
|
+
monkeypatch=monkeypatch,
|
|
97
|
+
)
|
|
98
|
+
with pytest.raises(AllowlistViolationError) as exc:
|
|
99
|
+
adapter._check_allowlist(
|
|
100
|
+
kind="self_hosted_service_breadth",
|
|
101
|
+
target="sshd.service",
|
|
102
|
+
bucket="systemd_units",
|
|
103
|
+
)
|
|
104
|
+
msg = str(exc.value)
|
|
105
|
+
assert "sshd.service" in msg
|
|
106
|
+
assert "systemd_units" in msg
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_observer_refuses_out_of_allowlist_backup_path(
|
|
110
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
111
|
+
) -> None:
|
|
112
|
+
"""A backup-repo path not in allowlists.backup_repo_paths MUST be refused."""
|
|
113
|
+
adapter = _adapter_with(
|
|
114
|
+
backup_repo_paths=(Path("/tmp/restic-fake"),),
|
|
115
|
+
tmp_path=tmp_path,
|
|
116
|
+
monkeypatch=monkeypatch,
|
|
117
|
+
)
|
|
118
|
+
with pytest.raises(AllowlistViolationError) as exc:
|
|
119
|
+
adapter._check_allowlist(
|
|
120
|
+
kind="backup_cadence",
|
|
121
|
+
target=Path("/etc/shadow"),
|
|
122
|
+
bucket="backup_repo_paths",
|
|
123
|
+
)
|
|
124
|
+
msg = str(exc.value)
|
|
125
|
+
assert "/etc/shadow" in msg
|
|
126
|
+
assert "backup_repo_paths" in msg
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_observer_refuses_out_of_allowlist_letsencrypt_dir(
|
|
130
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
131
|
+
) -> None:
|
|
132
|
+
"""A LE-cert dir not in allowlists.letsencrypt_cert_dirs MUST be refused."""
|
|
133
|
+
adapter = _adapter_with(
|
|
134
|
+
letsencrypt_cert_dirs=(Path("/tmp/letsencrypt-fake/live/example"),),
|
|
135
|
+
tmp_path=tmp_path,
|
|
136
|
+
monkeypatch=monkeypatch,
|
|
137
|
+
)
|
|
138
|
+
with pytest.raises(AllowlistViolationError) as exc:
|
|
139
|
+
adapter._check_allowlist(
|
|
140
|
+
kind="tls_hygiene",
|
|
141
|
+
target=Path("/etc/letsencrypt/live/other.example.com"),
|
|
142
|
+
bucket="letsencrypt_cert_dirs",
|
|
143
|
+
)
|
|
144
|
+
msg = str(exc.value)
|
|
145
|
+
assert "other.example.com" in msg
|
|
146
|
+
assert "letsencrypt_cert_dirs" in msg
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_observer_proceeds_for_in_allowlist_target(
|
|
150
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Positive control — in-allowlist targets across all three buckets pass."""
|
|
153
|
+
backup_path = Path("/tmp/restic-fake")
|
|
154
|
+
le_dir = Path("/tmp/letsencrypt-fake/live/example")
|
|
155
|
+
adapter = _adapter_with(
|
|
156
|
+
systemd_units=("nginx.service",),
|
|
157
|
+
backup_repo_paths=(backup_path,),
|
|
158
|
+
letsencrypt_cert_dirs=(le_dir,),
|
|
159
|
+
tmp_path=tmp_path,
|
|
160
|
+
monkeypatch=monkeypatch,
|
|
161
|
+
)
|
|
162
|
+
# None of these should raise.
|
|
163
|
+
adapter._check_allowlist(
|
|
164
|
+
kind="self_hosted_service_breadth",
|
|
165
|
+
target="nginx.service",
|
|
166
|
+
bucket="systemd_units",
|
|
167
|
+
)
|
|
168
|
+
adapter._check_allowlist(
|
|
169
|
+
kind="backup_cadence",
|
|
170
|
+
target=backup_path,
|
|
171
|
+
bucket="backup_repo_paths",
|
|
172
|
+
)
|
|
173
|
+
adapter._check_allowlist(
|
|
174
|
+
kind="tls_hygiene",
|
|
175
|
+
target=le_dir,
|
|
176
|
+
bucket="letsencrypt_cert_dirs",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def test_invoke_observer_runs_only_when_target_in_allowlist(
|
|
181
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
182
|
+
) -> None:
|
|
183
|
+
"""_invoke_observer awaits the invocation only when allowlist passes."""
|
|
184
|
+
backup_path = Path("/tmp/restic-fake")
|
|
185
|
+
adapter = _adapter_with(
|
|
186
|
+
backup_repo_paths=(backup_path,),
|
|
187
|
+
tmp_path=tmp_path,
|
|
188
|
+
monkeypatch=monkeypatch,
|
|
189
|
+
)
|
|
190
|
+
invocations: list[str] = []
|
|
191
|
+
|
|
192
|
+
async def fake_read() -> str:
|
|
193
|
+
invocations.append("ran")
|
|
194
|
+
return "ok"
|
|
195
|
+
|
|
196
|
+
# In-allowlist: invocation runs.
|
|
197
|
+
result = await adapter._invoke_observer(
|
|
198
|
+
kind="backup_cadence",
|
|
199
|
+
target=backup_path,
|
|
200
|
+
bucket="backup_repo_paths",
|
|
201
|
+
invocation=fake_read,
|
|
202
|
+
)
|
|
203
|
+
assert result == "ok"
|
|
204
|
+
assert invocations == ["ran"]
|
|
205
|
+
|
|
206
|
+
# Out-of-allowlist: invocation MUST NOT run.
|
|
207
|
+
with pytest.raises(AllowlistViolationError):
|
|
208
|
+
await adapter._invoke_observer(
|
|
209
|
+
kind="backup_cadence",
|
|
210
|
+
target=Path("/etc/shadow"),
|
|
211
|
+
bucket="backup_repo_paths",
|
|
212
|
+
invocation=fake_read,
|
|
213
|
+
)
|
|
214
|
+
assert invocations == ["ran"], (
|
|
215
|
+
"_invoke_observer must not await the invocation when the allowlist refuses"
|
|
216
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tests for self_hoster SQLite storage — mode 600 + observation insert."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from alter_runtime.adapters.household.self_hoster.storage import SelfHosterStorage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_db_file_mode_is_600(tmp_path: Path) -> None:
|
|
12
|
+
db_path = tmp_path / "self_hoster.db"
|
|
13
|
+
SelfHosterStorage(db_path=db_path)
|
|
14
|
+
mode = stat.S_IMODE(db_path.stat().st_mode)
|
|
15
|
+
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def test_record_observation_persists_row(tmp_path: Path) -> None:
|
|
19
|
+
db_path = tmp_path / "self_hoster.db"
|
|
20
|
+
storage = SelfHosterStorage(db_path=db_path)
|
|
21
|
+
assert await storage.observation_count() == 0
|
|
22
|
+
await storage.record_observation(kind="tls_hygiene", value_summary="2/2 valid")
|
|
23
|
+
await storage.record_observation(kind="backup_cadence", value_summary="last 1h ago")
|
|
24
|
+
assert await storage.observation_count() == 2
|
|
25
|
+
assert await storage.observation_count(kind="tls_hygiene") == 1
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Tests for self_hoster trait emitter — Clause-4 banlist + bands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from alter_runtime.adapters.household.self_hoster.traits import (
|
|
8
|
+
CLAUSE_4_BANLIST,
|
|
9
|
+
BannedTraitKindError,
|
|
10
|
+
SelfHosterTraits,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_banlist_contains_all_spec_kinds() -> None:
|
|
15
|
+
expected = {
|
|
16
|
+
"sysadmin-burnout",
|
|
17
|
+
"self-hoster-anxiety",
|
|
18
|
+
"patching-stress",
|
|
19
|
+
"tech-debt-shame",
|
|
20
|
+
"homelab-fatigue",
|
|
21
|
+
"CVE-panic",
|
|
22
|
+
"classroom-IT-administrator-suitability",
|
|
23
|
+
"employer-tier-engineering-grading",
|
|
24
|
+
"security-firm-risk-scoring",
|
|
25
|
+
}
|
|
26
|
+
assert expected.issubset(CLAUSE_4_BANLIST)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("banned_kind", sorted(CLAUSE_4_BANLIST))
|
|
30
|
+
def test_banned_kinds_are_refused(banned_kind: str) -> None:
|
|
31
|
+
traits = SelfHosterTraits()
|
|
32
|
+
with pytest.raises(BannedTraitKindError):
|
|
33
|
+
traits.emit(banned_kind, band="steady")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_five_canonical_subtraits_emit() -> None:
|
|
37
|
+
traits = SelfHosterTraits()
|
|
38
|
+
emissions = [
|
|
39
|
+
traits.emit_os_update_discipline("regular"),
|
|
40
|
+
traits.emit_backup_cadence("daily"),
|
|
41
|
+
traits.emit_self_hosted_service_breadth("broad"),
|
|
42
|
+
traits.emit_tls_hygiene("compliant"),
|
|
43
|
+
traits.emit_uptime_steward("steady"),
|
|
44
|
+
]
|
|
45
|
+
kinds = [e.kind for e in emissions]
|
|
46
|
+
assert kinds == [
|
|
47
|
+
"os_update_discipline",
|
|
48
|
+
"backup_cadence",
|
|
49
|
+
"self_hosted_service_breadth",
|
|
50
|
+
"tls_hygiene",
|
|
51
|
+
"uptime_steward",
|
|
52
|
+
]
|
|
53
|
+
for e in emissions:
|
|
54
|
+
assert e.provenance == "passive_local_multi_parameter"
|
|
55
|
+
assert e.stream == "self_hoster"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Self-hoster trait band emitters (Eco-15) with Clause-4 banlist.
|
|
2
|
+
|
|
3
|
+
Five banded sub-traits (stub spec §6):
|
|
4
|
+
|
|
5
|
+
* ``os_update_discipline``
|
|
6
|
+
* ``backup_cadence``
|
|
7
|
+
* ``self_hosted_service_breadth``
|
|
8
|
+
* ``tls_hygiene``
|
|
9
|
+
* ``uptime_steward``
|
|
10
|
+
|
|
11
|
+
Clause-4 banlist (stub spec §6):
|
|
12
|
+
|
|
13
|
+
* ``sysadmin-burnout``
|
|
14
|
+
* ``self-hoster-anxiety``
|
|
15
|
+
* ``patching-stress``
|
|
16
|
+
* ``tech-debt-shame``
|
|
17
|
+
* ``homelab-fatigue``
|
|
18
|
+
* ``CVE-panic``
|
|
19
|
+
* ``classroom-IT-administrator-suitability``
|
|
20
|
+
* ``employer-tier-engineering-grading``
|
|
21
|
+
* ``security-firm-risk-scoring``
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BackupCadenceBand",
|
|
33
|
+
"BannedTraitKindError",
|
|
34
|
+
"CLAUSE_4_BANLIST",
|
|
35
|
+
"OsUpdateDisciplineBand",
|
|
36
|
+
"SelfHosterTraitEmission",
|
|
37
|
+
"SelfHosterTraits",
|
|
38
|
+
"ServiceBreadthBand",
|
|
39
|
+
"TlsHygieneBand",
|
|
40
|
+
"UptimeStewardBand",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("alter_runtime.adapters.household.self_hoster.traits")
|
|
44
|
+
|
|
45
|
+
CLAUSE_4_BANLIST: frozenset[str] = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"sysadmin-burnout",
|
|
48
|
+
"self-hoster-anxiety",
|
|
49
|
+
"patching-stress",
|
|
50
|
+
"tech-debt-shame",
|
|
51
|
+
"homelab-fatigue",
|
|
52
|
+
"CVE-panic",
|
|
53
|
+
"classroom-IT-administrator-suitability",
|
|
54
|
+
"employer-tier-engineering-grading",
|
|
55
|
+
"security-firm-risk-scoring",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
OsUpdateDisciplineBand = Literal["lapsed", "irregular", "regular", "vigilant"]
|
|
60
|
+
BackupCadenceBand = Literal["absent", "sporadic", "weekly", "daily"]
|
|
61
|
+
ServiceBreadthBand = Literal["minimal", "narrow", "broad", "wide"]
|
|
62
|
+
TlsHygieneBand = Literal["lapsed", "uneven", "compliant", "exemplary"]
|
|
63
|
+
UptimeStewardBand = Literal["unstable", "intermittent", "steady", "exemplary"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BannedTraitKindError(RuntimeError):
|
|
67
|
+
"""Raised when a caller requests a Clause-4-banned trait kind."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SelfHosterTraitEmission(BaseModel):
|
|
71
|
+
kind: str = Field(description="Sub-trait kind, e.g. 'tls_hygiene'")
|
|
72
|
+
band: str = Field(description="Banded label (never numeric)")
|
|
73
|
+
provenance: str = Field(default="passive_local_multi_parameter")
|
|
74
|
+
stream: str = Field(default="self_hoster")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SelfHosterTraits:
|
|
78
|
+
def emit(self, kind: str, band: str) -> SelfHosterTraitEmission:
|
|
79
|
+
self._refuse_if_banned(kind)
|
|
80
|
+
# TODO(clause-3-identity-income): return/x402 hook attaches here per
|
|
81
|
+
# D-PROV-1 + clause-3 ratification. Each emission upward is the
|
|
82
|
+
# point at which Identity-Income return-flow is metered. Hook
|
|
83
|
+
# implementation lives outside this scaffold pass.
|
|
84
|
+
return SelfHosterTraitEmission(kind=kind, band=band)
|
|
85
|
+
|
|
86
|
+
def emit_os_update_discipline(self, band: OsUpdateDisciplineBand) -> SelfHosterTraitEmission:
|
|
87
|
+
return self.emit("os_update_discipline", band)
|
|
88
|
+
|
|
89
|
+
def emit_backup_cadence(self, band: BackupCadenceBand) -> SelfHosterTraitEmission:
|
|
90
|
+
return self.emit("backup_cadence", band)
|
|
91
|
+
|
|
92
|
+
def emit_self_hosted_service_breadth(self, band: ServiceBreadthBand) -> SelfHosterTraitEmission:
|
|
93
|
+
return self.emit("self_hosted_service_breadth", band)
|
|
94
|
+
|
|
95
|
+
def emit_tls_hygiene(self, band: TlsHygieneBand) -> SelfHosterTraitEmission:
|
|
96
|
+
return self.emit("tls_hygiene", band)
|
|
97
|
+
|
|
98
|
+
def emit_uptime_steward(self, band: UptimeStewardBand) -> SelfHosterTraitEmission:
|
|
99
|
+
return self.emit("uptime_steward", band)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _refuse_if_banned(kind: str) -> None:
|
|
103
|
+
if kind in CLAUSE_4_BANLIST:
|
|
104
|
+
logger.warning("self_hoster refusing Clause-4-banned trait kind=%s", kind)
|
|
105
|
+
raise BannedTraitKindError(f"trait kind {kind!r} is Clause-4 banned for self_hoster")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tapo ecosystem adapter (Eco-14) — hub-level multi-parameter composition.
|
|
2
|
+
|
|
3
|
+
Subscribes at hub level: N parameters at one logical location (the H100
|
|
4
|
+
hub). ``MultiParameterProvenanceRecord`` is composed across all paired
|
|
5
|
+
Tapo devices. Member-authored room-mapping (declared at pairing) stays
|
|
6
|
+
LOCAL and is never surfaced upward.
|
|
7
|
+
|
|
8
|
+
Three banded traits: ``household_appliance_stewardship``,
|
|
9
|
+
``household_routine_cadence``, ``ambient_stewardship``.
|
|
10
|
+
|
|
11
|
+
Spec: ``phase2-wave2-stub-specs-pack-2026-05-27.md`` §5.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from alter_runtime.adapters.household.tapo_ecosystem.adapter import (
|
|
15
|
+
TapoEcosystemAdapter,
|
|
16
|
+
)
|
|
17
|
+
from alter_runtime.adapters.household.tapo_ecosystem.traits import (
|
|
18
|
+
CLAUSE_4_BANLIST,
|
|
19
|
+
TapoEcosystemTraits,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = ["CLAUSE_4_BANLIST", "TapoEcosystemAdapter", "TapoEcosystemTraits"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tapo ecosystem adapter (Eco-14) — H100/H200 hub-level composition.
|
|
2
|
+
|
|
3
|
+
Subscribes to ``tapo/+/+/state`` and ``tapo/+/+/energy`` topic globs;
|
|
4
|
+
all paired Tapo devices under one hub compose into a single
|
|
5
|
+
``MultiParameterProvenanceRecord`` keyed by hub MAC.
|
|
6
|
+
|
|
7
|
+
Per-device traces never leave the daemon; only hub-level fused band
|
|
8
|
+
emits. Room-mapping authored by the member at pairing stays LOCAL.
|
|
9
|
+
|
|
10
|
+
Vendor-cloud posture (option b): TP-Link cloud REFUSED; python-kasa
|
|
11
|
+
local API accepted at the LAN bridge layer; Tapo Cloud account-link
|
|
12
|
+
REFUSED. This adapter only consumes events the household-bridge
|
|
13
|
+
republishes from the local LAN — it never originates cloud calls.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from alter_runtime.adapters.household._base import EventBusSubscriberBase
|
|
21
|
+
from alter_runtime.adapters.household.tapo_ecosystem.storage import (
|
|
22
|
+
TapoEcosystemStorage,
|
|
23
|
+
)
|
|
24
|
+
from alter_runtime.adapters.household.tapo_ecosystem.traits import (
|
|
25
|
+
TapoEcosystemTraits,
|
|
26
|
+
)
|
|
27
|
+
from alter_runtime.config import DaemonConfig
|
|
28
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"TAPO_HUB_ENERGY_TOPIC",
|
|
32
|
+
"TAPO_HUB_STATE_TOPIC",
|
|
33
|
+
"TapoEcosystemAdapter",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
TAPO_HUB_STATE_TOPIC: str = "tapo/+/+/state"
|
|
37
|
+
TAPO_HUB_ENERGY_TOPIC: str = "tapo/+/+/energy"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TapoEcosystemAdapter(EventBusSubscriberBase):
|
|
41
|
+
"""Tapo ecosystem (Eco-14) hub-level adapter."""
|
|
42
|
+
|
|
43
|
+
name: str = "tapo_ecosystem"
|
|
44
|
+
subscribe_topics: tuple[str, ...] = (
|
|
45
|
+
TAPO_HUB_STATE_TOPIC,
|
|
46
|
+
TAPO_HUB_ENERGY_TOPIC,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: DaemonConfig, bus: EventBus) -> None:
|
|
50
|
+
super().__init__(config, bus)
|
|
51
|
+
self._storage = TapoEcosystemStorage()
|
|
52
|
+
self._traits = TapoEcosystemTraits()
|
|
53
|
+
|
|
54
|
+
async def handle_event(self, payload: Any) -> None:
|
|
55
|
+
"""Normalise + persist one hub-level device event.
|
|
56
|
+
|
|
57
|
+
Expected payload shape (best-effort)::
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"hub_mac": "AA:BB:...",
|
|
61
|
+
"device_mac": "CC:DD:...",
|
|
62
|
+
"device_kind": "plug" | "temp_humid" | "motion" | "door",
|
|
63
|
+
"value": <float or dict>,
|
|
64
|
+
"ts": "2026-..."
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
``room`` may be present in the bridged payload but is dropped
|
|
68
|
+
before persistence — room-mapping stays at the bridge level.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(payload, dict):
|
|
71
|
+
self._logger.debug("tapo_ecosystem ignoring non-dict payload")
|
|
72
|
+
return
|
|
73
|
+
hub_mac = payload.get("hub_mac") or payload.get("hub")
|
|
74
|
+
device_mac = payload.get("device_mac") or payload.get("device_id")
|
|
75
|
+
device_kind = payload.get("device_kind") or payload.get("kind")
|
|
76
|
+
if not hub_mac or not device_mac or not device_kind:
|
|
77
|
+
self._logger.debug(
|
|
78
|
+
"tapo_ecosystem dropping incomplete event keys=%s",
|
|
79
|
+
list(payload.keys()),
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# TODO(eco-14): MultiParameterProvenanceRecord composition keyed by
|
|
84
|
+
# hub_mac — current scaffold persists per-device events flat.
|
|
85
|
+
# TODO(eco-14): compute household_appliance_stewardship band from
|
|
86
|
+
# per-plug Wh fingerprint diversity × rolling-30-day cadence.
|
|
87
|
+
# TODO(eco-14): compute household_routine_cadence band from
|
|
88
|
+
# T100/T110 event clustering against weekly self-baseline.
|
|
89
|
+
# TODO(eco-14): compute ambient_stewardship band from T310/T315
|
|
90
|
+
# temp/humidity ranges against AU climate-zone reference.
|
|
91
|
+
# TODO(eco-14): strip `room` from payload at the daemon boundary
|
|
92
|
+
# before any upward emit (room-mapping stays LOCAL).
|
|
93
|
+
await self._storage.record_event(
|
|
94
|
+
hub_mac=str(hub_mac),
|
|
95
|
+
device_mac=str(device_mac),
|
|
96
|
+
device_kind=str(device_kind),
|
|
97
|
+
ts=payload.get("ts") or payload.get("timestamp"),
|
|
98
|
+
)
|