alter-runtime 0.4.9__tar.gz → 0.4.10__tar.gz
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-0.4.9 → alter_runtime-0.4.10}/PKG-INFO +1 -1
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/__init__.py +2 -0
- alter_runtime-0.4.10/alter_runtime/adapters/loom_verdict_exporter.py +540 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/worktree_watcher.py +2 -3
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/config.py +60 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/daemon.py +23 -8
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/services/systemd/alter-runtime.service.in +6 -9
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/__init__.py +2 -2
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/inbox_writer.py +43 -2
- alter_runtime-0.4.10/alter_runtime/subscribers/loom_verdict_subscriber.py +340 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/pyproject.toml +1 -1
- alter_runtime-0.4.9/alter_runtime/subscribers/weave_intent_writer.py +0 -608
- alter_runtime-0.4.9/alter_runtime/weave/__init__.py +0 -21
- alter_runtime-0.4.9/alter_runtime/weave/resolver.py +0 -542
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/LICENSE +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/README.md +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/__init__.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/claude_jsonl_watcher.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/contract.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/git_watcher.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/cap_cache.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/cli.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/clients/__init__.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/clients/token_usage_client.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/consent.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/floor_loop.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/floor_preflight.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/http_auth.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/invocation_signing.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/messaging.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/notifiers/__init__.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/notifiers/desktop.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sdk/__init__.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sdk/client.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/service_install.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/services/__init__.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/services/launchd/com.alter.runtime.plist.in +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sockets/__init__.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sockets/dbus.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sockets/unix.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_cron_emitter.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_do_publisher.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_gc.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_writer.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/adapters_writer.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/agent_frames.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/attunement_refresher.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/bus.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/cache_writer.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/ceremony_echo.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/do_sse.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/doctrine_projection.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/ebpf.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/mcp_fallback.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/presence_feed_writer.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/presence_writer.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/session_presence.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/session_refresher.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/sse.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/update_loop.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/verified_apply.py +0 -0
- {alter_runtime-0.4.9 → alter_runtime-0.4.10}/hatch_build.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alter-runtime
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.10
|
|
4
4
|
Summary: ~Alter Identity Runtime - local daemon for the continuous identity field. Subscribes to per-~handle Cloudflare Durable Objects, exposes Unix socket + D-Bus + CLI surfaces, falls back gracefully to direct MCP polling.
|
|
5
5
|
Project-URL: Homepage, https://truealter.com
|
|
6
6
|
Project-URL: Documentation, https://truealter.com/docs/alter-runtime
|
|
@@ -14,10 +14,12 @@ runtime component.
|
|
|
14
14
|
|
|
15
15
|
from alter_runtime.adapters.claude_jsonl_watcher import ClaudeJsonlWatcher
|
|
16
16
|
from alter_runtime.adapters.git_watcher import GitWatcher
|
|
17
|
+
from alter_runtime.adapters.loom_verdict_exporter import LoomVerdictExporter
|
|
17
18
|
from alter_runtime.adapters.worktree_watcher import WorktreeWatcher
|
|
18
19
|
|
|
19
20
|
__all__ = [
|
|
20
21
|
"ClaudeJsonlWatcher",
|
|
21
22
|
"GitWatcher",
|
|
23
|
+
"LoomVerdictExporter",
|
|
22
24
|
"WorktreeWatcher",
|
|
23
25
|
]
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""LoomVerdictExporter - desktop-only producer for the read-only
|
|
2
|
+
cross-machine loom-verdict feed.
|
|
3
|
+
|
|
4
|
+
Design doc (alter monorepo, source of truth for this component):
|
|
5
|
+
``docs/scoping/loom-cross-machine-readonly-verdict-2026-07-09.md``
|
|
6
|
+
sections 3 (wire schema + strict serializer), 5 (device-bound trust), and
|
|
7
|
+
7 (patent boundary).
|
|
8
|
+
|
|
9
|
+
Shape
|
|
10
|
+
-----
|
|
11
|
+
|
|
12
|
+
Only the machine that IS the loom validator (today, Blake's desktop) runs
|
|
13
|
+
this component (``DaemonConfig.loom_verdict_exporter_enabled``, default
|
|
14
|
+
``False``). It tails the LOCAL verdict store the validator already writes
|
|
15
|
+
(``scripts/verdict_store.py`` in the alter monorepo, schema 2, one JSON
|
|
16
|
+
file per concern under ``~/.local/share/alter/verdicts/``) and, for every
|
|
17
|
+
new-or-changed row whose concern is a loom concern, POSTs a strict
|
|
18
|
+
four-field projection to
|
|
19
|
+
``POST /api/v1/identity-events/loom-verdict`` signed with the device's
|
|
20
|
+
registered ``AgentSigningKey`` (the same ES256 invocation-signing path
|
|
21
|
+
that already gates the daemon's MCP ``tools/call`` traffic - see
|
|
22
|
+
:mod:`alter_runtime.invocation_signing`).
|
|
23
|
+
|
|
24
|
+
This is the AMBIENT produce trigger only (design doc section 2): the
|
|
25
|
+
validator computes a standing verdict on its own write-settle cadence and
|
|
26
|
+
this component relays it. The REMOTE-REQUEST trigger (a thin client
|
|
27
|
+
asking the desktop to run a fresh validation against a coordinate it has
|
|
28
|
+
never seen) and the thin-client LAND-TIME ADMISSION gate that eventually
|
|
29
|
+
*consumes* a mirrored verdict are both explicitly OUT OF SCOPE here - they
|
|
30
|
+
belong to the Unit-4 land-kernel owner (design doc section 4's flagged
|
|
31
|
+
open boundary). This module only ever reads local files and POSTs; it
|
|
32
|
+
never runs a validator itself and never touches quarantined kernel code.
|
|
33
|
+
|
|
34
|
+
STRICT 4-FIELD WIRE ALLOWLIST (the patent-leak guard)
|
|
35
|
+
------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
The local verdict-store row carries a free-text ``detail`` field plus
|
|
38
|
+
``basis`` / ``basis_inputs`` dicts (absolute worktree paths today; a
|
|
39
|
+
future validator revision could plausibly write Patent-AR-class
|
|
40
|
+
arbitration/cascade reasoning into ``detail`` to explain *why* a verdict
|
|
41
|
+
landed where it did). :func:`row_to_wire_payload` is a strict allowlist
|
|
42
|
+
transform with two independent enforcement layers:
|
|
43
|
+
|
|
44
|
+
1. It reads ONLY ``row["verdict"]``, ``row["basis_digest"]`` (renamed to
|
|
45
|
+
the wire's ``basis_hash``), and ``row["observed_at"]``. ``detail``,
|
|
46
|
+
``basis``, ``basis_inputs``, and any other row key are never read by
|
|
47
|
+
this function - there is no code path for them to reach the wire.
|
|
48
|
+
2. Even if a future edit to this function accidentally read one of those
|
|
49
|
+
keys, constructing :class:`LoomVerdictWirePayload` (``extra="forbid"``)
|
|
50
|
+
by NAMING each field explicitly - never by splatting the row
|
|
51
|
+
(``**row``) - means an unexpected extra key raises ``ValidationError``
|
|
52
|
+
at construction time instead of silently forwarding.
|
|
53
|
+
|
|
54
|
+
See ``tests/test_loom_verdict_exporter.py`` for the leak-guard assertions.
|
|
55
|
+
|
|
56
|
+
Cross-platform
|
|
57
|
+
--------------
|
|
58
|
+
|
|
59
|
+
Pure ``pathlib`` + a portable poll loop (no ``inotify``/``watchdog``
|
|
60
|
+
dependency, no ``fcntl``): the verdict store changes at most once per
|
|
61
|
+
Loom-validate run, so a few seconds of poll latency is an acceptable
|
|
62
|
+
trade for identical behaviour on Linux, macOS, and Windows. Cursor state
|
|
63
|
+
is written via tempfile + ``os.replace`` (atomic rename on all three
|
|
64
|
+
platforms).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import asyncio
|
|
70
|
+
import contextlib
|
|
71
|
+
import json
|
|
72
|
+
import logging
|
|
73
|
+
import os
|
|
74
|
+
from datetime import datetime, timezone
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
77
|
+
|
|
78
|
+
import httpx
|
|
79
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
80
|
+
|
|
81
|
+
from alter_runtime.config import DaemonConfig, data_dir
|
|
82
|
+
from alter_runtime.daemon import Component
|
|
83
|
+
from alter_runtime.invocation_signing import build_invocation_signature
|
|
84
|
+
|
|
85
|
+
if TYPE_CHECKING:
|
|
86
|
+
from alter_runtime.config import Session, SessionRef
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"CURSOR_FILENAME",
|
|
90
|
+
"LOOM_CONCERN_PREFIX",
|
|
91
|
+
"LoomVerdictExporter",
|
|
92
|
+
"LoomVerdictWirePayload",
|
|
93
|
+
"default_cursor_path",
|
|
94
|
+
"row_to_wire_payload",
|
|
95
|
+
"verdict_store_dir",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
logger = logging.getLogger("alter_runtime.adapters.loom_verdict_exporter")
|
|
99
|
+
|
|
100
|
+
#: Only verdict-store rows whose ``concern`` starts with this prefix are
|
|
101
|
+
#: eligible for cross-machine export. This is the scoping half of the
|
|
102
|
+
#: patent-boundary guard: the local verdict store also carries unrelated
|
|
103
|
+
#: concerns (e.g. ``disclosure:...``, ``lesson-rung:...`` per
|
|
104
|
+
#: ``scripts/verdict_store.py``'s schema-2 docstring) that must never
|
|
105
|
+
#: leave the desktop. Only concerns the loom validator itself writes
|
|
106
|
+
#: belong on the wire.
|
|
107
|
+
LOOM_CONCERN_PREFIX = "loom:"
|
|
108
|
+
|
|
109
|
+
#: Cursor filename (within ``data_dir()``) tracking, per concern, the
|
|
110
|
+
#: ``observed_at`` of the last row successfully exported - survives
|
|
111
|
+
#: daemon restarts without re-emitting duplicates on every boot.
|
|
112
|
+
CURSOR_FILENAME = "loom-verdict-exporter-cursor.json"
|
|
113
|
+
|
|
114
|
+
#: Env var matching ``scripts/verdict_store.py``'s own ``store_dir()``
|
|
115
|
+
#: resolution NAME exactly. That module lives in the sibling alter
|
|
116
|
+
#: monorepo, not an installable dependency of alter-runtime, so its path
|
|
117
|
+
#: resolution is intentionally replicated (not imported) here. Any change
|
|
118
|
+
#: to the upstream store path/env-var must be mirrored in this constant.
|
|
119
|
+
_VERDICT_STORE_DIR_ENV = "ALTER_VERDICT_STORE_DIR"
|
|
120
|
+
|
|
121
|
+
#: Valid wire verdict values (matches the backend's
|
|
122
|
+
#: ``LoomVerdictIngestRequest.verdict`` Literal exactly).
|
|
123
|
+
_WIRE_VERDICTS = ("GREEN", "RED", "STALE", "UNKNOWN")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def verdict_store_dir() -> Path:
|
|
127
|
+
"""Resolve the local verdict-store directory the desktop validator writes.
|
|
128
|
+
|
|
129
|
+
Mirrors ``scripts/verdict_store.py:store_dir()`` (alter monorepo)
|
|
130
|
+
byte-for-byte: ``pathlib.Path.home()``-based, portable across Linux,
|
|
131
|
+
macOS, and Windows (this is the upstream producer's contract, not
|
|
132
|
+
XDG-namespaced - not ours to change; alter-runtime's own
|
|
133
|
+
:func:`alter_runtime.config.data_dir` is deliberately NOT reused here
|
|
134
|
+
because it would resolve to a different directory than the one the
|
|
135
|
+
validator actually writes to).
|
|
136
|
+
"""
|
|
137
|
+
override = os.environ.get(_VERDICT_STORE_DIR_ENV)
|
|
138
|
+
return Path(override) if override else Path.home() / ".local" / "share" / "alter" / "verdicts"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class LoomVerdictWirePayload(BaseModel):
|
|
142
|
+
"""The exact four-field allowlist the backend's
|
|
143
|
+
``LoomVerdictIngestRequest`` accepts (alter monorepo
|
|
144
|
+
``backend/app/schemas/identity_events.py``). ``extra="forbid"`` is the
|
|
145
|
+
second, independent enforcement layer for the patent-leak guard
|
|
146
|
+
described in the module docstring - never construct this by splatting
|
|
147
|
+
an upstream row (``**row``); name every field explicitly.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_config = ConfigDict(extra="forbid")
|
|
151
|
+
|
|
152
|
+
verdict: Literal["GREEN", "RED", "STALE", "UNKNOWN"]
|
|
153
|
+
basis_hash: str
|
|
154
|
+
observed_at: str
|
|
155
|
+
producer_id: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _decayed_verdict(row: dict[str, Any]) -> str | None:
|
|
159
|
+
"""Return the decay-honest lowercase verdict for a schema-2 row, or
|
|
160
|
+
``None`` if the row's verdict is missing/unrecognised.
|
|
161
|
+
|
|
162
|
+
Mirrors ``scripts/verdict_store.py``'s ``_apply_decay`` /
|
|
163
|
+
``_ttl_expired`` (replicated, not imported - see module docstring): a
|
|
164
|
+
``green``/``red`` row whose ``decay.ttl_s`` has elapsed since
|
|
165
|
+
``observed_at`` degrades to ``"stale"`` here too, so the exporter
|
|
166
|
+
never wires out a confidently green/red verdict that the LOCAL reader
|
|
167
|
+
itself would already treat as stale. The stored file is never
|
|
168
|
+
rewritten; this is read-time honesty only, exactly like the source
|
|
169
|
+
module.
|
|
170
|
+
"""
|
|
171
|
+
verdict = row.get("verdict")
|
|
172
|
+
if verdict not in ("green", "red", "stale", "unknown"):
|
|
173
|
+
return None
|
|
174
|
+
if verdict not in ("green", "red"):
|
|
175
|
+
return verdict
|
|
176
|
+
ttl = (row.get("decay") or {}).get("ttl_s")
|
|
177
|
+
if not ttl:
|
|
178
|
+
return verdict
|
|
179
|
+
observed_at = row.get("observed_at")
|
|
180
|
+
if not isinstance(observed_at, str):
|
|
181
|
+
return "stale"
|
|
182
|
+
try:
|
|
183
|
+
observed = datetime.fromisoformat(observed_at)
|
|
184
|
+
except ValueError:
|
|
185
|
+
return "stale" # An unparseable timestamp can never prove freshness.
|
|
186
|
+
if observed.tzinfo is None:
|
|
187
|
+
observed = observed.replace(tzinfo=timezone.utc)
|
|
188
|
+
if (datetime.now(timezone.utc) - observed).total_seconds() > ttl:
|
|
189
|
+
return "stale"
|
|
190
|
+
return verdict
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def row_to_wire_payload(row: dict[str, Any], *, producer_id: str) -> LoomVerdictWirePayload | None:
|
|
194
|
+
"""Strict allowlist transform: a verdict-store row -> the four-field
|
|
195
|
+
wire payload, or ``None`` when the row is malformed or ineligible.
|
|
196
|
+
|
|
197
|
+
``producer_id`` is supplied by the CALLER (derived from the local
|
|
198
|
+
device's own signing-key identity - see
|
|
199
|
+
:meth:`LoomVerdictExporter._producer_id`); it is NEVER read from
|
|
200
|
+
``row``. See the module docstring for the two-layer patent-leak guard
|
|
201
|
+
this function implements.
|
|
202
|
+
"""
|
|
203
|
+
if not isinstance(row, dict):
|
|
204
|
+
return None
|
|
205
|
+
if row.get("schema") != 2:
|
|
206
|
+
return None
|
|
207
|
+
concern = row.get("concern")
|
|
208
|
+
if not isinstance(concern, str) or not concern.startswith(LOOM_CONCERN_PREFIX):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
decayed = _decayed_verdict(row)
|
|
212
|
+
if decayed is None:
|
|
213
|
+
return None
|
|
214
|
+
wire_verdict = decayed.upper()
|
|
215
|
+
if wire_verdict not in _WIRE_VERDICTS:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
basis_hash = row.get("basis_digest")
|
|
219
|
+
observed_at = row.get("observed_at")
|
|
220
|
+
if not isinstance(basis_hash, str) or not basis_hash:
|
|
221
|
+
return None
|
|
222
|
+
if not isinstance(observed_at, str) or not observed_at:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
return LoomVerdictWirePayload(
|
|
227
|
+
verdict=wire_verdict, # type: ignore[arg-type]
|
|
228
|
+
basis_hash=basis_hash,
|
|
229
|
+
observed_at=observed_at,
|
|
230
|
+
producer_id=producer_id,
|
|
231
|
+
)
|
|
232
|
+
except ValidationError:
|
|
233
|
+
# Fail-closed: an unexpected shape is dropped, never forwarded
|
|
234
|
+
# partially. Should be unreachable given the checks above; kept as
|
|
235
|
+
# the second enforcement layer described in the module docstring.
|
|
236
|
+
logger.warning(
|
|
237
|
+
"loom_verdict_exporter: row for concern=%r failed the wire-payload "
|
|
238
|
+
"allowlist validation - dropping (never forwarding a partial row)",
|
|
239
|
+
concern,
|
|
240
|
+
)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Cursor persistence (survives daemon restarts without re-emitting)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def default_cursor_path() -> Path:
|
|
250
|
+
return data_dir() / CURSOR_FILENAME
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_cursor(path: Path) -> dict[str, str]:
|
|
254
|
+
if not path.exists():
|
|
255
|
+
return {}
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
258
|
+
if isinstance(data, dict):
|
|
259
|
+
return {str(k): str(v) for k, v in data.items() if isinstance(v, str)}
|
|
260
|
+
except (OSError, json.JSONDecodeError):
|
|
261
|
+
pass
|
|
262
|
+
return {}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _save_cursor(path: Path, cursor: dict[str, str]) -> None:
|
|
266
|
+
"""Atomically write the cursor file (tempfile + fsync + os.replace).
|
|
267
|
+
|
|
268
|
+
No ``fcntl`` - single-writer-at-a-time by construction (one exporter
|
|
269
|
+
instance polls at a time) and this pattern is portable to Windows,
|
|
270
|
+
unlike the ``flock``-based idiom used by the append-only JSONL writers
|
|
271
|
+
elsewhere in this package.
|
|
272
|
+
"""
|
|
273
|
+
tmp = path.with_suffix(".tmp")
|
|
274
|
+
try:
|
|
275
|
+
tmp.write_text(json.dumps(cursor, indent=2, sort_keys=True), encoding="utf-8")
|
|
276
|
+
with tmp.open("rb") as f:
|
|
277
|
+
os.fsync(f.fileno())
|
|
278
|
+
os.replace(tmp, path)
|
|
279
|
+
except OSError as exc:
|
|
280
|
+
logger.warning("loom_verdict_exporter: cursor write failed: %s", exc)
|
|
281
|
+
with contextlib.suppress(OSError):
|
|
282
|
+
tmp.unlink()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# LoomVerdictExporter
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class LoomVerdictExporter(Component):
|
|
291
|
+
"""Tails the local loom-concern verdict-store rows and re-emits each
|
|
292
|
+
as a signed ``loom_verdict`` POST to the backend.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
config:
|
|
297
|
+
Loaded :class:`DaemonConfig`. Uses ``loom_verdict_exporter_enabled``
|
|
298
|
+
(checked by the daemon wiring, not this class),
|
|
299
|
+
``loom_verdict_exporter_poll_interval_seconds``, and
|
|
300
|
+
``loom_verdict_endpoint``.
|
|
301
|
+
session:
|
|
302
|
+
Authenticated CLI :class:`Session` or a live :class:`SessionRef`.
|
|
303
|
+
Supplies the bearer JWT and the ES256 signing-key identity
|
|
304
|
+
(``signing_kid`` / ``signing_key_fingerprint``) used to build the
|
|
305
|
+
``Mcp-Invocation-Signature`` header.
|
|
306
|
+
http_client:
|
|
307
|
+
Optional ``httpx.AsyncClient`` override for tests.
|
|
308
|
+
store_dir:
|
|
309
|
+
Override the verdict-store directory for tests (defaults to
|
|
310
|
+
:func:`verdict_store_dir`).
|
|
311
|
+
poll_interval_seconds:
|
|
312
|
+
Override the poll cadence for tests.
|
|
313
|
+
cursor_path:
|
|
314
|
+
Override the cursor file path for tests (defaults to
|
|
315
|
+
:func:`default_cursor_path`, i.e. ``data_dir()``). Tests MUST pass
|
|
316
|
+
this (or redirect ``XDG_DATA_HOME``) - otherwise the exporter
|
|
317
|
+
reads/writes the real ``~/.local/share/alter-runtime/`` cursor
|
|
318
|
+
file, leaking cross-test-run state onto the developer's machine.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
name = "loom_verdict_exporter"
|
|
322
|
+
|
|
323
|
+
def __init__(
|
|
324
|
+
self,
|
|
325
|
+
config: DaemonConfig,
|
|
326
|
+
session: "Session | SessionRef",
|
|
327
|
+
*,
|
|
328
|
+
http_client: httpx.AsyncClient | None = None,
|
|
329
|
+
store_dir: Path | None = None,
|
|
330
|
+
poll_interval_seconds: float | None = None,
|
|
331
|
+
cursor_path: Path | None = None,
|
|
332
|
+
) -> None:
|
|
333
|
+
self._config = config
|
|
334
|
+
from alter_runtime.config import SessionRef as _SessionRef
|
|
335
|
+
|
|
336
|
+
if isinstance(session, _SessionRef):
|
|
337
|
+
self._session_ref: "_SessionRef | None" = session
|
|
338
|
+
self._session: "Session" = session.current
|
|
339
|
+
else:
|
|
340
|
+
self._session_ref = None
|
|
341
|
+
self._session = session # type: ignore[assignment]
|
|
342
|
+
|
|
343
|
+
self._http_client = http_client
|
|
344
|
+
self._owns_client = http_client is None
|
|
345
|
+
self._store_dir = store_dir if store_dir is not None else verdict_store_dir()
|
|
346
|
+
self._poll_interval = (
|
|
347
|
+
poll_interval_seconds
|
|
348
|
+
if poll_interval_seconds is not None
|
|
349
|
+
else config.loom_verdict_exporter_poll_interval_seconds
|
|
350
|
+
)
|
|
351
|
+
self._cursor_path = cursor_path if cursor_path is not None else default_cursor_path()
|
|
352
|
+
self._stop_event = asyncio.Event()
|
|
353
|
+
self._cursor: dict[str, str] = _load_cursor(self._cursor_path)
|
|
354
|
+
|
|
355
|
+
# Test/observability introspection counters.
|
|
356
|
+
self.export_count = 0
|
|
357
|
+
self.skip_count = 0
|
|
358
|
+
|
|
359
|
+
def _live_session(self) -> "Session":
|
|
360
|
+
if self._session_ref is not None:
|
|
361
|
+
return self._session_ref.current
|
|
362
|
+
return self._session
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _producer_id(session: "Session") -> str | None:
|
|
366
|
+
"""Derive a device-identity string from the local ES256 signing
|
|
367
|
+
key. NEVER reads a value from the verdict row or any
|
|
368
|
+
caller-suppliable input - this is the daemon's own credential.
|
|
369
|
+
|
|
370
|
+
The backend derives ``producer_id`` server-side from the verified
|
|
371
|
+
signing key and IGNORES any caller-supplied value
|
|
372
|
+
(``backend/app/api/v1/endpoints/identity_events.py::
|
|
373
|
+
_derive_loom_producer_id`` + ``post_loom_verdict``, foundation
|
|
374
|
+
``skein/feat-loom-verdict-feed-foundation`` @ 8be402150). We still
|
|
375
|
+
send the device's own key identity here for schema compatibility
|
|
376
|
+
and forward-honesty; the backend overwrites it, so the served value
|
|
377
|
+
is authoritative regardless of what this exporter sends.
|
|
378
|
+
"""
|
|
379
|
+
fingerprint = getattr(session, "signing_key_fingerprint", None)
|
|
380
|
+
if fingerprint:
|
|
381
|
+
return f"es256-fp:{fingerprint}"
|
|
382
|
+
kid = getattr(session, "signing_kid", None)
|
|
383
|
+
if kid:
|
|
384
|
+
return f"es256-kid:{kid}"
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
# ------------------------------------------------------------------
|
|
388
|
+
# Component lifecycle
|
|
389
|
+
# ------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
async def run(self) -> None:
|
|
392
|
+
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(10.0))
|
|
393
|
+
logger.info(
|
|
394
|
+
"loom_verdict_exporter starting store_dir=%s endpoint=%s poll_interval=%.1fs",
|
|
395
|
+
self._store_dir,
|
|
396
|
+
self._config.loom_verdict_endpoint,
|
|
397
|
+
self._poll_interval,
|
|
398
|
+
)
|
|
399
|
+
try:
|
|
400
|
+
while not self._stop_event.is_set():
|
|
401
|
+
try:
|
|
402
|
+
await self._poll_once(client)
|
|
403
|
+
except Exception as exc: # noqa: BLE001 - one bad poll must not kill the loop
|
|
404
|
+
logger.warning("loom_verdict_exporter: poll iteration failed: %s", exc)
|
|
405
|
+
try:
|
|
406
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=self._poll_interval)
|
|
407
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
408
|
+
pass
|
|
409
|
+
finally:
|
|
410
|
+
if self._owns_client:
|
|
411
|
+
with contextlib.suppress(Exception):
|
|
412
|
+
await client.aclose()
|
|
413
|
+
logger.info("loom_verdict_exporter stopped")
|
|
414
|
+
|
|
415
|
+
async def stop(self) -> None:
|
|
416
|
+
"""Cooperative shutdown - releases the poll loop."""
|
|
417
|
+
self._stop_event.set()
|
|
418
|
+
|
|
419
|
+
# ------------------------------------------------------------------
|
|
420
|
+
# Poll + emit
|
|
421
|
+
# ------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
async def _poll_once(self, client: httpx.AsyncClient) -> None:
|
|
424
|
+
"""Scan the verdict-store directory once and emit any new rows."""
|
|
425
|
+
if not self._store_dir.is_dir():
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
session = self._live_session()
|
|
429
|
+
producer_id = self._producer_id(session)
|
|
430
|
+
if producer_id is None:
|
|
431
|
+
logger.warning(
|
|
432
|
+
"loom_verdict_exporter: no signing key available "
|
|
433
|
+
"(session.signing_kid unset) - cannot emit a device-bound "
|
|
434
|
+
"loom_verdict; skipping this poll"
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
for path in sorted(self._store_dir.glob("*.json")):
|
|
439
|
+
try:
|
|
440
|
+
row = json.loads(path.read_text(encoding="utf-8"))
|
|
441
|
+
except (OSError, json.JSONDecodeError):
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
payload = row_to_wire_payload(row, producer_id=producer_id)
|
|
445
|
+
if payload is None:
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
concern = row["concern"]
|
|
449
|
+
last_observed_at = self._cursor.get(concern)
|
|
450
|
+
if last_observed_at is not None and payload.observed_at <= last_observed_at:
|
|
451
|
+
continue # Already exported this (or a newer) observation.
|
|
452
|
+
|
|
453
|
+
ok = await self._emit(client, session, payload)
|
|
454
|
+
if ok:
|
|
455
|
+
self._cursor[concern] = payload.observed_at
|
|
456
|
+
_save_cursor(self._cursor_path, self._cursor)
|
|
457
|
+
self.export_count += 1
|
|
458
|
+
else:
|
|
459
|
+
self.skip_count += 1
|
|
460
|
+
|
|
461
|
+
async def _emit(
|
|
462
|
+
self,
|
|
463
|
+
client: httpx.AsyncClient,
|
|
464
|
+
session: "Session",
|
|
465
|
+
payload: LoomVerdictWirePayload,
|
|
466
|
+
) -> bool:
|
|
467
|
+
"""POST one wire payload, signed. Returns True on confirmed success."""
|
|
468
|
+
# tool_args MUST equal exactly what the server recomputes as
|
|
469
|
+
# ``body.model_dump(exclude={"thread_id"})`` - since this payload
|
|
470
|
+
# carries no thread_id field at all, model_dump() already matches
|
|
471
|
+
# byte-for-byte (the canonicalisation itself is delegated entirely
|
|
472
|
+
# to build_invocation_signature / the server's canonical_args_sha256,
|
|
473
|
+
# both sort_keys=True + compact separators + ensure_ascii=False).
|
|
474
|
+
tool_args = payload.model_dump()
|
|
475
|
+
jws = build_invocation_signature(session, "loom_verdict.emit", tool_args)
|
|
476
|
+
if jws is None:
|
|
477
|
+
logger.warning(
|
|
478
|
+
"loom_verdict_exporter: could not build Mcp-Invocation-Signature "
|
|
479
|
+
"(missing/unreadable signing key) - skipping this export"
|
|
480
|
+
)
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
headers = {
|
|
484
|
+
"Authorization": f"Bearer {session.jwt}",
|
|
485
|
+
"Content-Type": "application/json",
|
|
486
|
+
"Accept": "application/json",
|
|
487
|
+
"Mcp-Invocation-Signature": jws,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
response = await client.post(
|
|
492
|
+
self._config.loom_verdict_endpoint,
|
|
493
|
+
json=tool_args,
|
|
494
|
+
headers=headers,
|
|
495
|
+
)
|
|
496
|
+
except httpx.HTTPError as exc:
|
|
497
|
+
logger.warning("loom_verdict_exporter: POST transport error: %s", exc)
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
if response.status_code >= 400:
|
|
501
|
+
logger.warning(
|
|
502
|
+
"loom_verdict_exporter: backend rejected loom_verdict status=%d body=%s",
|
|
503
|
+
response.status_code,
|
|
504
|
+
response.text[:256],
|
|
505
|
+
)
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
# The endpoint returns HTTP 200 with {"ok": false, ...} when the DO
|
|
509
|
+
# itself rejects the envelope - a plain status check misses that.
|
|
510
|
+
try:
|
|
511
|
+
body = response.json()
|
|
512
|
+
except ValueError:
|
|
513
|
+
body = None
|
|
514
|
+
if isinstance(body, dict) and body.get("ok") is False:
|
|
515
|
+
logger.warning(
|
|
516
|
+
"loom_verdict_exporter: backend ok=false do_status=%s error=%s",
|
|
517
|
+
body.get("do_status"),
|
|
518
|
+
body.get("error"),
|
|
519
|
+
)
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
logger.info(
|
|
523
|
+
"loom_verdict_exporter: emitted verdict=%s basis_hash=%s status=%d",
|
|
524
|
+
payload.verdict,
|
|
525
|
+
payload.basis_hash[:12],
|
|
526
|
+
response.status_code,
|
|
527
|
+
)
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
# Test introspection
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def store_dir(self) -> Path:
|
|
536
|
+
return self._store_dir
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def cursor(self) -> dict[str, str]:
|
|
540
|
+
return self._cursor
|
|
@@ -17,9 +17,8 @@ Signals
|
|
|
17
17
|
"ts": "2026-05-21T12:34:56.789012+00:00",
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
Published on the ``local.signal`` topic
|
|
21
|
-
|
|
22
|
-
record (coarse "dangling edge" degradation path).
|
|
20
|
+
Published on the ``local.signal`` topic for any downstream subscriber that
|
|
21
|
+
consumes working-tree save events.
|
|
23
22
|
|
|
24
23
|
Design notes
|
|
25
24
|
------------
|
|
@@ -899,6 +899,43 @@ class DaemonConfig:
|
|
|
899
899
|
#: Override via ``ALTER_RUNTIME_DOCTRINE_PROJECTION_INTERVAL_SECONDS``.
|
|
900
900
|
doctrine_projection_interval_seconds: float = 60.0
|
|
901
901
|
|
|
902
|
+
# --- Loom verdict cross-machine feed (read-only foundation) ----------
|
|
903
|
+
# Design doc: docs/scoping/loom-cross-machine-readonly-verdict-2026-07-09.md
|
|
904
|
+
# (alter monorepo). Two independent components share this config block:
|
|
905
|
+
# the desktop LoomVerdictExporter (producer side) and the thin-client
|
|
906
|
+
# LoomVerdictSubscriber (read-only mirror side). A single host may run
|
|
907
|
+
# either, both, or neither depending on whether it IS the loom
|
|
908
|
+
# validator or merely reads a served verdict.
|
|
909
|
+
#: Master toggle for :class:`LoomVerdictExporter`. Default ``False``:
|
|
910
|
+
#: only the machine that IS the loom validator (today, Blake's desktop)
|
|
911
|
+
#: should ever tail its local verdict store and re-emit rows
|
|
912
|
+
#: cross-machine. A thin client has no validator verdicts to export;
|
|
913
|
+
#: enabling this there is a harmless no-op (the poll finds nothing
|
|
914
|
+
#: matching the ``loom:`` concern prefix) but the default stays off so
|
|
915
|
+
#: the capability is opt-in per the design's single-producer-of-record
|
|
916
|
+
#: posture (section 2). Enable via
|
|
917
|
+
#: ``ALTER_RUNTIME_LOOM_VERDICT_EXPORTER=1`` on the validator host only.
|
|
918
|
+
loom_verdict_exporter_enabled: bool = False
|
|
919
|
+
#: Poll interval for the exporter's verdict-store tail. A portable
|
|
920
|
+
#: poll-based tail (no inotify/watchdog dependency) so Windows, macOS,
|
|
921
|
+
#: and Linux behave identically - the verdict store is written
|
|
922
|
+
#: relatively infrequently (once per Loom-validate run), so a few
|
|
923
|
+
#: seconds of latency is an acceptable trade for portability. Override
|
|
924
|
+
#: via ``ALTER_RUNTIME_LOOM_VERDICT_EXPORTER_POLL_INTERVAL_SECONDS``.
|
|
925
|
+
loom_verdict_exporter_poll_interval_seconds: float = 5.0
|
|
926
|
+
#: Backend REST endpoint the exporter POSTs the strict 4-field
|
|
927
|
+
#: allowlist payload to (``POST .../identity-events/loom-verdict``).
|
|
928
|
+
#: Override via ``ALTER_RUNTIME_LOOM_VERDICT_ENDPOINT``.
|
|
929
|
+
loom_verdict_endpoint: str = "https://api.truealter.com/api/v1/identity-events/loom-verdict"
|
|
930
|
+
|
|
931
|
+
#: Master toggle for :class:`LoomVerdictSubscriber` (thin-client
|
|
932
|
+
#: read-only mirror). Default ``True``, mirroring
|
|
933
|
+
#: :class:`~alter_runtime.subscribers.agent_frames.AgentFrameSubscriber`'s
|
|
934
|
+
#: always-on posture: it is read-only, cheap (filters the existing
|
|
935
|
+
#: DO-SSE bus stream), and safe on every host including the validator's
|
|
936
|
+
#: own desktop. Disable via ``ALTER_RUNTIME_LOOM_VERDICT_SUBSCRIBER=0``.
|
|
937
|
+
loom_verdict_subscriber_enabled: bool = True
|
|
938
|
+
|
|
902
939
|
@classmethod
|
|
903
940
|
def load(cls) -> DaemonConfig:
|
|
904
941
|
"""Load config from environment + YAML file + defaults.
|
|
@@ -1053,6 +1090,28 @@ class DaemonConfig:
|
|
|
1053
1090
|
except ValueError:
|
|
1054
1091
|
pass # Ignore malformed override; default stands.
|
|
1055
1092
|
|
|
1093
|
+
# --- Loom verdict cross-machine feed -----------------------------
|
|
1094
|
+
_lve_flag = os.environ.get("ALTER_RUNTIME_LOOM_VERDICT_EXPORTER", "").lower()
|
|
1095
|
+
if _lve_flag in ("0", "false", "no", "off"):
|
|
1096
|
+
config.loom_verdict_exporter_enabled = False
|
|
1097
|
+
elif _lve_flag in ("1", "true", "yes", "on"):
|
|
1098
|
+
config.loom_verdict_exporter_enabled = True
|
|
1099
|
+
if lve_interval := os.environ.get(
|
|
1100
|
+
"ALTER_RUNTIME_LOOM_VERDICT_EXPORTER_POLL_INTERVAL_SECONDS"
|
|
1101
|
+
):
|
|
1102
|
+
try:
|
|
1103
|
+
config.loom_verdict_exporter_poll_interval_seconds = float(lve_interval)
|
|
1104
|
+
except ValueError:
|
|
1105
|
+
pass # Ignore malformed override; default stands.
|
|
1106
|
+
if lv_endpoint := os.environ.get("ALTER_RUNTIME_LOOM_VERDICT_ENDPOINT"):
|
|
1107
|
+
config.loom_verdict_endpoint = lv_endpoint
|
|
1108
|
+
env_pinned_endpoints.add("loom_verdict_endpoint")
|
|
1109
|
+
_lvs_flag = os.environ.get("ALTER_RUNTIME_LOOM_VERDICT_SUBSCRIBER", "").lower()
|
|
1110
|
+
if _lvs_flag in ("0", "false", "no", "off"):
|
|
1111
|
+
config.loom_verdict_subscriber_enabled = False
|
|
1112
|
+
elif _lvs_flag in ("1", "true", "yes", "on"):
|
|
1113
|
+
config.loom_verdict_subscriber_enabled = True
|
|
1114
|
+
|
|
1056
1115
|
if level := os.environ.get("ALTER_RUNTIME_LOG_LEVEL"):
|
|
1057
1116
|
config.log_level = level
|
|
1058
1117
|
if socket := os.environ.get("ALTER_RUNTIME_SOCKET"):
|
|
@@ -1173,6 +1232,7 @@ class DaemonConfig:
|
|
|
1173
1232
|
_require_https("org_alter_caps_endpoint", config.org_alter_caps_endpoint)
|
|
1174
1233
|
_require_https("org_alter_presence_endpoint", config.org_alter_presence_endpoint)
|
|
1175
1234
|
_require_https("do_publish_url", config.do_publish_url)
|
|
1235
|
+
_require_https("loom_verdict_endpoint", config.loom_verdict_endpoint)
|
|
1176
1236
|
|
|
1177
1237
|
return config
|
|
1178
1238
|
|