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.
Files changed (62) hide show
  1. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/PKG-INFO +1 -1
  2. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/__init__.py +2 -0
  3. alter_runtime-0.4.10/alter_runtime/adapters/loom_verdict_exporter.py +540 -0
  4. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/worktree_watcher.py +2 -3
  5. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/config.py +60 -0
  6. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/daemon.py +23 -8
  7. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/services/systemd/alter-runtime.service.in +6 -9
  8. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/__init__.py +2 -2
  9. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/inbox_writer.py +43 -2
  10. alter_runtime-0.4.10/alter_runtime/subscribers/loom_verdict_subscriber.py +340 -0
  11. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/pyproject.toml +1 -1
  12. alter_runtime-0.4.9/alter_runtime/subscribers/weave_intent_writer.py +0 -608
  13. alter_runtime-0.4.9/alter_runtime/weave/__init__.py +0 -21
  14. alter_runtime-0.4.9/alter_runtime/weave/resolver.py +0 -542
  15. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/LICENSE +0 -0
  16. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/README.md +0 -0
  17. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/__init__.py +0 -0
  18. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/claude_jsonl_watcher.py +0 -0
  19. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/contract.py +0 -0
  20. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/adapters/git_watcher.py +0 -0
  21. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/cap_cache.py +0 -0
  22. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/cli.py +0 -0
  23. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/clients/__init__.py +0 -0
  24. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/clients/token_usage_client.py +0 -0
  25. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/consent.py +0 -0
  26. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/floor_loop.py +0 -0
  27. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/floor_preflight.py +0 -0
  28. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/http_auth.py +0 -0
  29. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/invocation_signing.py +0 -0
  30. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/messaging.py +0 -0
  31. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/notifiers/__init__.py +0 -0
  32. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/notifiers/desktop.py +0 -0
  33. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sdk/__init__.py +0 -0
  34. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sdk/client.py +0 -0
  35. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/service_install.py +0 -0
  36. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/services/__init__.py +0 -0
  37. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/services/launchd/com.alter.runtime.plist.in +0 -0
  38. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sockets/__init__.py +0 -0
  39. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sockets/dbus.py +0 -0
  40. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/sockets/unix.py +0 -0
  41. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_cron_emitter.py +0 -0
  42. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_do_publisher.py +0 -0
  43. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_gc.py +0 -0
  44. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/active_sessions_writer.py +0 -0
  45. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/adapters_writer.py +0 -0
  46. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/agent_frames.py +0 -0
  47. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/attunement_refresher.py +0 -0
  48. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/bus.py +0 -0
  49. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/cache_writer.py +0 -0
  50. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/ceremony_echo.py +0 -0
  51. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/do_sse.py +0 -0
  52. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/doctrine_projection.py +0 -0
  53. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/ebpf.py +0 -0
  54. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/mcp_fallback.py +0 -0
  55. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/presence_feed_writer.py +0 -0
  56. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/presence_writer.py +0 -0
  57. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/session_presence.py +0 -0
  58. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/session_refresher.py +0 -0
  59. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/subscribers/sse.py +0 -0
  60. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/update_loop.py +0 -0
  61. {alter_runtime-0.4.9 → alter_runtime-0.4.10}/alter_runtime/verified_apply.py +0 -0
  62. {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.9
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. ``WeaveIntentWriter`` subscribes and
21
- projects the event into ``weave-intent.jsonl`` as a coarse ``worktree_edit``
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