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,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
@@ -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
+ )