lightfall-utils 0.1.0__tar.gz → 0.2.0__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 (55) hide show
  1. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/PKG-INFO +7 -1
  2. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/README.md +1 -0
  3. lightfall_utils-0.2.0/docs/ipc.md +206 -0
  4. lightfall_utils-0.2.0/docs/superpowers/plans/2026-09-10-ipc-extraction.md +429 -0
  5. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/pyproject.toml +4 -0
  6. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/_version.py +2 -2
  7. lightfall_utils-0.2.0/src/lightfall_utils/ipc/__init__.py +45 -0
  8. lightfall_utils-0.2.0/src/lightfall_utils/ipc/local_server.py +172 -0
  9. lightfall_utils-0.2.0/src/lightfall_utils/ipc/protocol.py +59 -0
  10. lightfall_utils-0.2.0/src/lightfall_utils/ipc/service.py +890 -0
  11. lightfall_utils-0.2.0/src/lightfall_utils/ipc/trust.py +142 -0
  12. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/theming/manager.py +29 -0
  13. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/threads.py +34 -6
  14. lightfall_utils-0.2.0/tests/ipc/__init__.py +0 -0
  15. lightfall_utils-0.2.0/tests/ipc/test_actions.py +239 -0
  16. lightfall_utils-0.2.0/tests/ipc/test_broker_roundtrip.py +193 -0
  17. lightfall_utils-0.2.0/tests/ipc/test_capability_channels.py +249 -0
  18. lightfall_utils-0.2.0/tests/ipc/test_discover_peers.py +129 -0
  19. lightfall_utils-0.2.0/tests/ipc/test_local_server.py +148 -0
  20. lightfall_utils-0.2.0/tests/ipc/test_protocol.py +57 -0
  21. lightfall_utils-0.2.0/tests/ipc/test_service.py +347 -0
  22. lightfall_utils-0.2.0/tests/ipc/test_trust.py +70 -0
  23. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_threads.py +46 -0
  24. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/.github/workflows/release.yml +0 -0
  25. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/.gitignore +0 -0
  26. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/LEGAL.md +0 -0
  27. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/LICENSE.md +0 -0
  28. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/__init__.py +0 -0
  29. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/ca/__init__.py +0 -0
  30. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/ca/context.py +0 -0
  31. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/ca/pv.py +0 -0
  32. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/caproto_shutdown.py +0 -0
  33. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/config/__init__.py +0 -0
  34. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/config/layers.py +0 -0
  35. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/config/manager.py +0 -0
  36. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/log_buffer.py +0 -0
  37. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/logging.py +0 -0
  38. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/py.typed +0 -0
  39. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/qt_affinity.py +0 -0
  40. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/theming/__init__.py +0 -0
  41. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/theming/builtin.py +0 -0
  42. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/theming/provider.py +0 -0
  43. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/src/lightfall_utils/theming/registry.py +0 -0
  44. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/ca_ioc.py +0 -0
  45. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/conftest.py +0 -0
  46. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_ca_context.py +0 -0
  47. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_ca_pv.py +0 -0
  48. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_caproto_shutdown.py +0 -0
  49. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_config_layers.py +0 -0
  50. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_config_manager.py +0 -0
  51. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_log_buffer.py +0 -0
  52. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_logging.py +0 -0
  53. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_package_hygiene.py +0 -0
  54. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_qt_affinity.py +0 -0
  55. {lightfall_utils-0.1.0 → lightfall_utils-0.2.0}/tests/test_theming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lightfall-utils
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Shared Qt/EPICS infrastructure for ALS control applications: managed threading, loguru logging, semantic theming, layered config, and a caproto/Qt bridge
5
5
  Author: ALS Controls Team
6
6
  License-Expression: BSD-3-Clause
@@ -8,6 +8,7 @@ License-File: LEGAL.md
8
8
  License-File: LICENSE.md
9
9
  Requires-Python: >=3.11
10
10
  Requires-Dist: loguru>=0.7
11
+ Requires-Dist: nats-py>=2.0
11
12
  Requires-Dist: pydantic>=2.0
12
13
  Requires-Dist: pyside6>=6.6
13
14
  Requires-Dist: pyyaml>=6.0
@@ -15,7 +16,9 @@ Provides-Extra: ca
15
16
  Requires-Dist: caproto>=1.1; extra == 'ca'
16
17
  Provides-Extra: dev
17
18
  Requires-Dist: caproto>=1.1; extra == 'dev'
19
+ Requires-Dist: nats-server-bin>=2.14; extra == 'dev'
18
20
  Requires-Dist: pyright>=1.1; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
19
22
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
20
23
  Requires-Dist: pytest-qt>=4.2; extra == 'dev'
21
24
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -24,6 +27,8 @@ Provides-Extra: docs
24
27
  Requires-Dist: myst-parser>=2.0; extra == 'docs'
25
28
  Requires-Dist: sphinx-immaterial>=0.12; extra == 'docs'
26
29
  Requires-Dist: sphinx<9.0,>=7.0; extra == 'docs'
30
+ Provides-Extra: local-nats
31
+ Requires-Dist: nats-server-bin>=2.14; extra == 'local-nats'
27
32
  Provides-Extra: multihomed
28
33
  Requires-Dist: netifaces>=0.11; (sys_platform != 'darwin') and extra == 'multihomed'
29
34
  Description-Content-Type: text/markdown
@@ -39,6 +44,7 @@ Extracted from [Lightfall](https://github.com/als-controls/lightfall). Modules:
39
44
  - `lightfall_utils.qt_affinity` — GUI-thread assertion helpers (`gui_thread_only`)
40
45
  - `lightfall_utils.config` — priority-layered YAML config with pydantic validation
41
46
  - `lightfall_utils.theming` — semantic design tokens, theme registry/manager, QSS generation
47
+ - `lightfall_utils.ipc` — NATS-backed `IPCService` with a trust handshake, per-session capability channels, discovery and a structured reply protocol; optional `LocalNatsServer` for broker-less development (`pip install lightfall-utils[local-nats]`).
42
48
  - `lightfall_utils.ca` — caproto → Qt signal bridge (`SharedContext`, `PV`); requires the `ca` extra
43
49
  - `lightfall_utils.caproto_shutdown` — drains caproto's user-callback thread pools cleanly at application shutdown
44
50
 
@@ -9,6 +9,7 @@ Extracted from [Lightfall](https://github.com/als-controls/lightfall). Modules:
9
9
  - `lightfall_utils.qt_affinity` — GUI-thread assertion helpers (`gui_thread_only`)
10
10
  - `lightfall_utils.config` — priority-layered YAML config with pydantic validation
11
11
  - `lightfall_utils.theming` — semantic design tokens, theme registry/manager, QSS generation
12
+ - `lightfall_utils.ipc` — NATS-backed `IPCService` with a trust handshake, per-session capability channels, discovery and a structured reply protocol; optional `LocalNatsServer` for broker-less development (`pip install lightfall-utils[local-nats]`).
12
13
  - `lightfall_utils.ca` — caproto → Qt signal bridge (`SharedContext`, `PV`); requires the `ca` extra
13
14
  - `lightfall_utils.caproto_shutdown` — drains caproto's user-callback thread pools cleanly at application shutdown
14
15
 
@@ -0,0 +1,206 @@
1
+ # IPC: NATS-backed Inter-Process Communication
2
+
3
+ The `lightfall_utils.ipc` module provides a Qt-integrated IPC service built on NATS, with trust handshaking, per-session capability channels, and a structured reply protocol.
4
+
5
+ ## Overview
6
+
7
+ An `IPCService` manages a connection to a NATS broker, publishes and subscribes to a topic tree, handles request/reply patterns for actions, and mints per-session capability tokens for trusted applications. The protocol uses JSON and versioning to ensure compatibility across services.
8
+
9
+ ## Topic Structure
10
+
11
+ All user-visible subjects live under a configurable prefix (e.g., `"als.lightfall"`). Well-known discovery uses no prefix:
12
+
13
+ - **Auth handshake:** `{prefix}.auth.request` — app sends `{"app_name", "version"}`, service replies with approval/denial and session token
14
+ - **Meta discovery (no auth required):** `{prefix}.meta.actions` — list registered actions; `{prefix}.meta.events` — list registered events
15
+ - **Peer discovery (no prefix):** `_lightfall.discover` — scatter-gather request/reply to find all live services
16
+ - **Session capability channels:** `{prefix}.session.{token}.>` — per-app capability subject after successful auth; routes to trusted actions with identity attached
17
+
18
+ ## Trust Handshake Sequence
19
+
20
+ 1. App sends a request to `{prefix}.auth.request` with `{"app_name", "version"}` and a reply subject
21
+ 2. Service evaluates trust via `evaluate_trust(app_name)` (delegates to a configured `TrustManager`)
22
+ - If `UNKNOWN`, the service may prompt the user with a `TrustDialog`
23
+ - If `DENIED`, reply with `{"status": "denied", ...}`
24
+ - If `APPROVED`, mint a session capability channel token and reply with `{"status": "approved", "session_token": "...", ...}`
25
+ 3. App uses the session token in requests to trusted actions: sends on `{prefix}.session.{token}.action_name` instead of the bare `{prefix}.action_name`
26
+ 4. Service attaches identity to the request: `data["_identity"] = {"app_name", "session_token"}`
27
+
28
+ When an app re-authenticates, existing tokens for that app are revoked.
29
+
30
+ ## Registering an Action
31
+
32
+ ```python
33
+ from lightfall_utils.ipc import IPCService, ErrorCodes, ok_reply
34
+
35
+ # Initialize the service (connecting to NATS server at construction)
36
+ service = IPCService("nats://localhost:4222", topic_prefix="als.myapp")
37
+ service.start() # Connect to the NATS broker on a background thread
38
+
39
+ # Define app-specific error codes
40
+ app_errors = ErrorCodes("not_found", "validation_failed")
41
+
42
+ # Register an action
43
+ def handle_compute(subject: str, data: dict, reply: str | None) -> None:
44
+ """Process incoming 'compute' action requests."""
45
+ try:
46
+ value = data.get("value")
47
+ if value is None:
48
+ response = app_errors.reply("validation_failed", "Missing 'value'")
49
+ else:
50
+ result = value * 2
51
+ response = ok_reply(result=result)
52
+ except Exception as e:
53
+ response = app_errors.reply("unknown", str(e))
54
+
55
+ if reply:
56
+ service.reply(reply, response)
57
+
58
+ handle = service.register_action(
59
+ "compute",
60
+ handle_compute,
61
+ description="Double a numeric value",
62
+ schema={
63
+ "type": "object",
64
+ "properties": {"value": {"type": "number"}},
65
+ "required": ["value"],
66
+ },
67
+ )
68
+ ```
69
+
70
+ Later, unregister with `handle.unregister()`.
71
+
72
+ ### Trusted (capability-channel-only) actions
73
+
74
+ Pass `trusted=True` to `register_action()` to make an action reachable only
75
+ through a session capability channel (see "Trust Handshake Sequence" above).
76
+ A request sent to its bare, non-session subject gets a structured `denied`
77
+ error reply instead of reaching the handler; the request must instead be
78
+ sent to `{prefix}.session.{token}.<suffix>` with a token obtained from a
79
+ completed `auth.request` handshake.
80
+
81
+ ## Building Auth Responses
82
+
83
+ Use `build_auth_response()` to construct handshake replies:
84
+
85
+ ```python
86
+ # Approved with extra fields (e.g., session metadata)
87
+ response = service.build_auth_response(
88
+ approved=True,
89
+ app_name="remote-app",
90
+ extra={"session_id": "s-12345"},
91
+ )
92
+ # Returns: {"status": "approved", "session_token": "...", "session_id": "s-12345", ...}
93
+
94
+ # Denied
95
+ response = service.build_auth_response(
96
+ approved=False,
97
+ reason="Application not registered",
98
+ )
99
+ # Returns: {"status": "denied", "reason": "Application not registered", ...}
100
+ ```
101
+
102
+ ## Error Codes
103
+
104
+ The protocol defines base error codes that all services understand:
105
+
106
+ ```python
107
+ from lightfall_utils.ipc import BASE_ERROR_CODES
108
+
109
+ # BASE_ERROR_CODES = {"busy", "limits", "timeout", "unknown", "denied", "bad_request", "version_mismatch"}
110
+ ```
111
+
112
+ Applications extend this set with their own codes:
113
+
114
+ ```python
115
+ from lightfall_utils.ipc import ErrorCodes, error_reply
116
+
117
+ app_codes = ErrorCodes("custom_error_1", "custom_error_2")
118
+
119
+ # Use in error replies
120
+ reply_data = app_codes.reply("custom_error_1", "Something went wrong")
121
+ # Returns: {"status": "error", "code": "custom_error_1", "message": "...", "contract_version": 1}
122
+ ```
123
+
124
+ ## Peer Discovery
125
+
126
+ Discover all services on the bus:
127
+
128
+ ```python
129
+ def on_peers_found(peers):
130
+ """Called with a list of {instance_id, display_name, prefix, is_self} dicts."""
131
+ for peer in peers:
132
+ print(f"Peer: {peer['display_name']} at {peer['prefix']}")
133
+
134
+ # Non-blocking; callback runs on the Qt main thread
135
+ service.discover_peers(on_peers_found, timeout_ms=500)
136
+ ```
137
+
138
+ ## Events
139
+
140
+ Register events for meta-discovery (no subscription created; only for catalog):
141
+
142
+ ```python
143
+ service.register_event(
144
+ "events.scan.completed",
145
+ description="Emitted when a scan finishes",
146
+ schema={"type": "object", "properties": {"scan_id": {"type": "string"}}},
147
+ )
148
+
149
+ # Later, publish events directly; note that publish() takes the full subject
150
+ # (not a suffix), so use service.topic() to apply the prefix
151
+ service.publish(service.topic("events.scan.completed"), {"scan_id": "s-789"})
152
+ ```
153
+
154
+ ## Local Development: LocalNatsServer
155
+
156
+ For testing without an external NATS broker, use `LocalNatsServer`:
157
+
158
+ ```python
159
+ from lightfall_utils.ipc import LocalNatsServer, IPCService
160
+
161
+ server = LocalNatsServer()
162
+ server.start() # Starts a managed nats-server process
163
+
164
+ service = IPCService("nats://localhost:4222", topic_prefix="als.test")
165
+ service.start() # Connect to the local server
166
+
167
+ # ...
168
+
169
+ service.stop()
170
+ server.stop()
171
+ ```
172
+
173
+ Requires `pip install lightfall-utils[local-nats]`.
174
+
175
+ ## Trust Management
176
+
177
+ Use a `TrustManager` to track which applications are trusted:
178
+
179
+ ```python
180
+ from lightfall_utils.ipc import TrustManager, TrustState
181
+
182
+ trust = TrustManager()
183
+ service.set_trust_manager(trust)
184
+
185
+ # In auth handler
186
+ state = service.evaluate_trust("remote-app")
187
+ if state == TrustState.UNKNOWN:
188
+ # Prompt user with TrustDialog
189
+ from PySide6.QtWidgets import QDialog
190
+ from lightfall_utils.ipc import TrustDialog
191
+ dialog = TrustDialog("remote-app", app_version="1.0", parent=main_window)
192
+ if dialog.exec() == QDialog.Accepted:
193
+ trust.approve("remote-app")
194
+ else:
195
+ trust.deny("remote-app")
196
+ elif state == TrustState.APPROVED:
197
+ # Grant access
198
+ pass
199
+ elif state == TrustState.DENIED:
200
+ # Reject
201
+ pass
202
+ ```
203
+
204
+ ## Protocol Contract
205
+
206
+ All replies carry a `contract_version` field (currently `1`). Applications should check this to detect incompatibilities.
@@ -0,0 +1,429 @@
1
+ # `lightfall_utils.ipc` Extraction Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Move Lightfall's NATS IPC layer — `IPCService`, `TrustManager`/`TrustDialog`, the reply protocol, `LocalNatsServer` — into `lightfall-utils` so Camphor (and any NCS app) can be a participant on the bus with the same trust handshake and capability channels.
6
+
7
+ **Architecture:** A *move*, not a rewrite. Four modules come across from `lightfall/src/lightfall/ipc/` and `lightfall/src/lightfall/remote/protocol.py` with their tests; the only code changes are the two Lightfall couplings — `invoke_in_main_thread` (already in `lightfall_utils.threads`) and `build_auth_response`, which stops importing `SessionManager` and takes the app-specific reply fields as a parameter. `get_ipc_service()` (a `ServiceRegistry` lookup) stays in Lightfall. The error-code set becomes extensible per app.
8
+
9
+ **Tech Stack:** Python 3.11+, PySide6, `nats-py>=2.0`, `loguru`; `nats-server-bin` optional for tests and the local broker; pytest + pytest-qt.
10
+
11
+ **Spec:** `C:\Users\rp\workspace\catfish\docs\superpowers\specs\2026-09-10-agent-support-design.md` §3.1, §5.5, §8 step 1. Source of the moved code: `C:\Users\rp\PycharmProjects\ncs\lightfall\src\lightfall\ipc\{service,trust,local_server}.py`, `...\lightfall\remote\protocol.py`, tests under `...\lightfall\tests\ipc\`.
12
+
13
+ ## Global Constraints
14
+
15
+ - Public names and behaviour are preserved: `IPCService`, `ActionInfo`, `EventInfo`, `TrustManager`, `TrustState`, `TrustDialog`, `LocalNatsServer` and its three error classes, `resolve_nats_binary`, `nats_binary_version`, `probe_nats`, `CONTRACT_VERSION = 1`, `ok_reply`, `error_reply`. Lightfall must be able to re-export them unchanged (its plan, step 2).
16
+ - Wire contract unchanged: subjects, `auth.request` reply shape (`status`, `session_token`, `contract_version`, optional `reason`), `meta.actions`/`meta.events`, discover reply, capability-channel routing, `denied` on bare trusted subjects.
17
+ - No `lightfall.*` import anywhere under `src/lightfall_utils/ipc/`. A hygiene test enforces it.
18
+ - `nats-py>=2.0` becomes a core dependency of lightfall-utils; `nats-server-bin>=2.14` is a new optional extra `local-nats` and a `dev` dependency.
19
+ - Repo conventions: ruff (line length 100), pyright, pytest-qt; commits carry the attribution footer in use in this session.
20
+ - Release as `0.2.0` (tag) when the plan is complete; Lightfall and Camphor floor on it.
21
+
22
+ ---
23
+
24
+ ### Task 1: Dependencies and the extensible reply protocol
25
+
26
+ **Files:**
27
+ - Modify: `pyproject.toml` (dependencies, optional-dependencies)
28
+ - Create: `src/lightfall_utils/ipc/__init__.py`, `src/lightfall_utils/ipc/protocol.py`
29
+ - Test: `tests/ipc/__init__.py`, `tests/ipc/test_protocol.py`
30
+
31
+ **Interfaces:**
32
+ - Produces: `CONTRACT_VERSION: int = 1`; `BASE_ERROR_CODES: frozenset[str]`; `ok_reply(**fields) -> dict`; `error_reply(code, message, *, extra_codes: Iterable[str] = ()) -> dict`; `ErrorCodes` helper (`ErrorCodes(*app_codes).reply(code, message)`).
33
+
34
+ - [ ] **Step 1: Add the dependencies**
35
+
36
+ In `pyproject.toml`:
37
+
38
+ ```toml
39
+ dependencies = [
40
+ "PySide6>=6.6",
41
+ "loguru>=0.7",
42
+ "pydantic>=2.0",
43
+ "pyyaml>=6.0",
44
+ "nats-py>=2.0",
45
+ ]
46
+
47
+ [project.optional-dependencies]
48
+ ca = ["caproto>=1.1"]
49
+ local-nats = ["nats-server-bin>=2.14"]
50
+ multihomed = ["netifaces>=0.11; sys_platform != 'darwin'"]
51
+ dev = [
52
+ "caproto>=1.1",
53
+ "nats-server-bin>=2.14",
54
+ "pytest>=8.0",
55
+ "pytest-cov>=4.0",
56
+ "pytest-qt>=4.2",
57
+ "ruff>=0.1",
58
+ "pyright>=1.1",
59
+ ]
60
+ ```
61
+
62
+ Run: `.venv/Scripts/python.exe -m pip install -e ".[dev]"`
63
+
64
+ - [ ] **Step 2: Write the failing protocol tests**
65
+
66
+ ```python
67
+ # tests/ipc/test_protocol.py
68
+ import pytest
69
+
70
+ from lightfall_utils.ipc.protocol import (
71
+ BASE_ERROR_CODES,
72
+ CONTRACT_VERSION,
73
+ ErrorCodes,
74
+ error_reply,
75
+ ok_reply,
76
+ )
77
+
78
+
79
+ def test_ok_reply_carries_the_contract_version():
80
+ assert ok_reply(status="ok", value=3) == {"status": "ok", "value": 3, "contract_version": 1}
81
+ assert CONTRACT_VERSION == 1
82
+
83
+
84
+ def test_error_reply_shape_for_a_base_code():
85
+ assert error_reply("denied", "nope") == {
86
+ "status": "error", "code": "denied", "message": "nope", "contract_version": 1,
87
+ }
88
+
89
+
90
+ def test_lightfall_s_codes_are_all_in_the_base_set():
91
+ # The seven codes Lightfall's remote-control contract v1 documents.
92
+ assert {"busy", "limits", "timeout", "unknown", "denied", "bad_request",
93
+ "version_mismatch"} <= BASE_ERROR_CODES
94
+
95
+
96
+ def test_an_unknown_code_is_a_programming_error():
97
+ with pytest.raises(ValueError):
98
+ error_reply("not_a_code", "x")
99
+
100
+
101
+ def test_an_app_extends_the_set_without_touching_the_base():
102
+ codes = ErrorCodes("too_large", "confirmation_required")
103
+ assert codes.reply("too_large", "1.2 MiB > 1 MiB")["code"] == "too_large"
104
+ assert codes.reply("denied", "bare subject")["code"] == "denied" # base still valid
105
+ with pytest.raises(ValueError):
106
+ codes.reply("busy_beaver", "x")
107
+ assert "too_large" not in BASE_ERROR_CODES
108
+ ```
109
+
110
+ - [ ] **Step 3: Run to verify failure**
111
+
112
+ Run: `.venv/Scripts/python.exe -m pytest tests/ipc/test_protocol.py -q`
113
+ Expected: FAIL — `ModuleNotFoundError: lightfall_utils.ipc`
114
+
115
+ - [ ] **Step 4: Implement `protocol.py`**
116
+
117
+ ```python
118
+ # src/lightfall_utils/ipc/protocol.py
119
+ """Reply protocol for NCS IPC contracts (v1).
120
+
121
+ Every reply -- success or error -- carries ``contract_version`` so a client
122
+ can detect a mismatch. Errors are structured: ``{status: "error", code,
123
+ message, contract_version}``. The base code set is Lightfall's remote-control
124
+ contract; an application adds its own with :class:`ErrorCodes` rather than by
125
+ editing this set, so two apps on one bus never disagree about a base code.
126
+ """
127
+
128
+ from __future__ import annotations
129
+
130
+ from collections.abc import Iterable
131
+ from typing import Any
132
+
133
+ __all__ = ["BASE_ERROR_CODES", "CONTRACT_VERSION", "ErrorCodes", "error_reply", "ok_reply"]
134
+
135
+ CONTRACT_VERSION = 1
136
+
137
+ BASE_ERROR_CODES = frozenset(
138
+ {"busy", "limits", "timeout", "unknown", "denied", "bad_request", "version_mismatch"}
139
+ )
140
+
141
+
142
+ def ok_reply(**fields: Any) -> dict:
143
+ """A success reply carrying ``contract_version``."""
144
+ return {**fields, "contract_version": CONTRACT_VERSION}
145
+
146
+
147
+ def error_reply(code: str, message: str, *, extra_codes: Iterable[str] = ()) -> dict:
148
+ """A structured error reply. ``code`` must be a base code or one of ``extra_codes``."""
149
+ if code not in BASE_ERROR_CODES and code not in set(extra_codes):
150
+ raise ValueError(f"Unknown error code: {code!r}")
151
+ return {
152
+ "status": "error",
153
+ "code": code,
154
+ "message": message,
155
+ "contract_version": CONTRACT_VERSION,
156
+ }
157
+
158
+
159
+ class ErrorCodes:
160
+ """An application's error vocabulary: the base set plus its own codes."""
161
+
162
+ def __init__(self, *app_codes: str) -> None:
163
+ self.codes: frozenset[str] = BASE_ERROR_CODES | frozenset(app_codes)
164
+
165
+ def reply(self, code: str, message: str) -> dict:
166
+ return error_reply(code, message, extra_codes=self.codes)
167
+ ```
168
+
169
+ `src/lightfall_utils/ipc/__init__.py` for now:
170
+
171
+ ```python
172
+ """NATS-backed IPC for NCS applications: service, trust handshake, protocol, local broker."""
173
+
174
+ from lightfall_utils.ipc.protocol import (
175
+ BASE_ERROR_CODES,
176
+ CONTRACT_VERSION,
177
+ ErrorCodes,
178
+ error_reply,
179
+ ok_reply,
180
+ )
181
+
182
+ __all__ = ["BASE_ERROR_CODES", "CONTRACT_VERSION", "ErrorCodes", "error_reply", "ok_reply"]
183
+ ```
184
+
185
+ - [ ] **Step 5: Run to verify pass, lint, commit**
186
+
187
+ Run: `.venv/Scripts/python.exe -m pytest tests/ipc -q && .venv/Scripts/python.exe -m ruff check src tests`
188
+ Expected: 5 passed, lint clean.
189
+
190
+ ```bash
191
+ git add pyproject.toml src/lightfall_utils/ipc tests/ipc
192
+ git commit -m "feat(ipc): reply protocol with an app-extensible error vocabulary; nats-py dependency"
193
+ ```
194
+
195
+ ---
196
+
197
+ ### Task 2: Move `TrustManager` / `TrustDialog`
198
+
199
+ **Files:**
200
+ - Create: `src/lightfall_utils/ipc/trust.py` (from `lightfall/src/lightfall/ipc/trust.py`, 144 lines)
201
+ - Test: `tests/ipc/test_trust.py` (from `lightfall/tests/ipc/test_trust.py`)
202
+
203
+ **Interfaces:**
204
+ - Produces: `TrustState` (`UNKNOWN`, `APPROVED`, `DENIED`), `TrustManager` (`check`, `approve`, `deny`, `revoke`, `clear`, `list_approved`/whatever the source exposes — preserved verbatim), `TrustDialog(app_name, app_version, parent=None)`.
205
+
206
+ - [ ] **Step 1: Copy the source module and its test verbatim**
207
+
208
+ ```bash
209
+ cp ../lightfall/src/lightfall/ipc/trust.py src/lightfall_utils/ipc/trust.py
210
+ cp ../lightfall/tests/ipc/test_trust.py tests/ipc/test_trust.py
211
+ ```
212
+
213
+ - [ ] **Step 2: Fix imports**
214
+
215
+ In `tests/ipc/test_trust.py`: `from lightfall.ipc.trust import ...` → `from lightfall_utils.ipc.trust import ...`. `trust.py` itself imports only stdlib and `PySide6.QtWidgets`; verify with `grep -n "^from lightfall" src/lightfall_utils/ipc/trust.py` → no output.
216
+
217
+ - [ ] **Step 3: Run the moved tests**
218
+
219
+ Run: `.venv/Scripts/python.exe -m pytest tests/ipc/test_trust.py -q`
220
+ Expected: all pass (the module is unchanged).
221
+
222
+ - [ ] **Step 4: Export and commit**
223
+
224
+ Add to `src/lightfall_utils/ipc/__init__.py`: `from lightfall_utils.ipc.trust import TrustDialog, TrustManager, TrustState` and extend `__all__`.
225
+
226
+ ```bash
227
+ git add src/lightfall_utils/ipc/trust.py src/lightfall_utils/ipc/__init__.py tests/ipc/test_trust.py
228
+ git commit -m "feat(ipc): move TrustManager/TrustDialog from Lightfall"
229
+ ```
230
+
231
+ ---
232
+
233
+ ### Task 3: Move `LocalNatsServer`
234
+
235
+ **Files:**
236
+ - Create: `src/lightfall_utils/ipc/local_server.py` (from `lightfall/src/lightfall/ipc/local_server.py`, 175 lines)
237
+ - Test: `tests/ipc/test_local_server.py` (from Lightfall)
238
+
239
+ **Interfaces:**
240
+ - Produces: `LocalNatsServer(port=4222, host="127.0.0.1")` with `start(timeout_s)`, `stop()`, `url`; `LocalNatsServerError`, `NatsBinaryNotFoundError`, `NatsPortInUseError`, `NatsReadinessTimeoutError`; `resolve_nats_binary()`, `nats_binary_version(path)`, `probe_nats(host, port, timeout)`.
241
+
242
+ - [ ] **Step 1: Copy module and test; fix imports**
243
+
244
+ ```bash
245
+ cp ../lightfall/src/lightfall/ipc/local_server.py src/lightfall_utils/ipc/local_server.py
246
+ cp ../lightfall/tests/ipc/test_local_server.py tests/ipc/test_local_server.py
247
+ ```
248
+
249
+ `local_server.py` imports stdlib + `loguru` only. In the test replace `lightfall.ipc` with `lightfall_utils.ipc` (both the `from lightfall.ipc import local_server` and `from lightfall.ipc.local_server import (...)` forms).
250
+
251
+ - [ ] **Step 2: Run**
252
+
253
+ Run: `.venv/Scripts/python.exe -m pytest tests/ipc/test_local_server.py -q`
254
+ Expected: pass (`nats-server-bin` is in `dev`, so the bundled binary resolves).
255
+
256
+ - [ ] **Step 3: Export and commit**
257
+
258
+ Add the class, errors and three functions to `__init__.py` exports.
259
+
260
+ ```bash
261
+ git add src/lightfall_utils/ipc/local_server.py src/lightfall_utils/ipc/__init__.py tests/ipc/test_local_server.py
262
+ git commit -m "feat(ipc): move LocalNatsServer from Lightfall"
263
+ ```
264
+
265
+ ---
266
+
267
+ ### Task 4: Move `IPCService`, decoupled from Lightfall
268
+
269
+ **Files:**
270
+ - Create: `src/lightfall_utils/ipc/service.py` (from `lightfall/src/lightfall/ipc/service.py`, 932 lines)
271
+ - Test: `tests/ipc/test_service.py`, `tests/ipc/test_actions.py`, `tests/ipc/test_capability_channels.py`, `tests/ipc/test_discover_peers.py` (from Lightfall's `tests/ipc/`)
272
+
273
+ **Interfaces:**
274
+ - Produces: `IPCService(nats_url, topic_prefix="", parent=None)` with every existing method; **changed**: `build_auth_response(*, approved: bool, reason: str = "", app_name: str | None = None, extra: Mapping[str, Any] | None = None) -> dict` — no `session`/`tiled_url`; the approved reply is `{"status": "approved", **extra, "contract_version": 1, "session_token": ...}`. **Removed here**: `get_ipc_service()` (Lightfall keeps it; it needs `ServiceRegistry`).
275
+ - Consumes: `lightfall_utils.threads.invoke_in_main_thread`; `lightfall_utils.ipc.trust`; `lightfall_utils.ipc.protocol` (`CONTRACT_VERSION`, `error_reply`).
276
+
277
+ - [ ] **Step 1: Copy the module and the four tests**
278
+
279
+ ```bash
280
+ cp ../lightfall/src/lightfall/ipc/service.py src/lightfall_utils/ipc/service.py
281
+ for t in test_service test_actions test_capability_channels test_discover_peers; do
282
+ cp ../lightfall/tests/ipc/$t.py tests/ipc/$t.py
283
+ done
284
+ ```
285
+
286
+ - [ ] **Step 2: Rewrite the imports in `service.py`**
287
+
288
+ Replace:
289
+
290
+ ```python
291
+ from lightfall.ipc.trust import TrustManager, TrustState
292
+ from lightfall.remote.protocol import CONTRACT_VERSION, error_reply
293
+ from lightfall.utils.threads import invoke_in_main_thread
294
+ ```
295
+
296
+ with:
297
+
298
+ ```python
299
+ from lightfall_utils.ipc.protocol import CONTRACT_VERSION, error_reply
300
+ from lightfall_utils.ipc.trust import TrustManager, TrustState
301
+ from lightfall_utils.threads import invoke_in_main_thread
302
+ ```
303
+
304
+ Delete `get_ipc_service` (lines 923–932 of the source) and drop it from `__all__`.
305
+
306
+ - [ ] **Step 3: Decouple `build_auth_response`**
307
+
308
+ Replace the method body (source lines 329–395) with:
309
+
310
+ ```python
311
+ def build_auth_response(
312
+ self,
313
+ *,
314
+ approved: bool,
315
+ reason: str = "",
316
+ app_name: str | None = None,
317
+ extra: Mapping[str, Any] | None = None,
318
+ ) -> dict:
319
+ """Build the reply to an ``auth.request`` handshake.
320
+
321
+ ``extra`` carries the application's own approved-reply fields --
322
+ Lightfall adds ``tiled_token``, ``tiled_url`` and ``session_id``; an
323
+ app with nothing to add passes none. When ``app_name`` is given and the
324
+ request is approved, a session capability channel is minted for it and
325
+ its token returned as ``session_token``. A denial carries ``reason``
326
+ when non-empty.
327
+ """
328
+ if approved:
329
+ response: dict = {"status": "approved", **(dict(extra) if extra else {})}
330
+ response["contract_version"] = CONTRACT_VERSION
331
+ if app_name:
332
+ response["session_token"] = self.mint_session_channel(app_name)
333
+ return response
334
+ response = {"status": "denied", "contract_version": CONTRACT_VERSION}
335
+ if reason:
336
+ response["reason"] = reason
337
+ return response
338
+ ```
339
+
340
+ Add `from collections.abc import Mapping` to the imports if missing.
341
+
342
+ - [ ] **Step 4: Fix the moved tests**
343
+
344
+ - Every `from lightfall.ipc.service import` → `from lightfall_utils.ipc.service import`; `from lightfall.ipc.trust` → `from lightfall_utils.ipc.trust`.
345
+ - `tests/ipc/test_service.py` around source line 285 patches `lightfall.auth.session.SessionManager` to test the Tiled fields of `build_auth_response`. Replace that test with one for the new signature:
346
+
347
+ ```python
348
+ def test_build_auth_response_approved_mints_a_channel_and_carries_extra_fields():
349
+ svc = IPCService("nats://unused", topic_prefix="als.test")
350
+ svc.set_trust_manager(TrustManager())
351
+ reply = svc.build_auth_response(
352
+ approved=True, app_name="camphor-mcp", extra={"tiled_url": "https://t"}
353
+ )
354
+ assert reply["status"] == "approved"
355
+ assert reply["tiled_url"] == "https://t"
356
+ assert reply["contract_version"] == 1
357
+ assert isinstance(reply["session_token"], str) and len(reply["session_token"]) > 20
358
+ assert svc.session_channel_count == 1
359
+
360
+
361
+ def test_build_auth_response_denied_carries_reason_only_when_given():
362
+ svc = IPCService("nats://unused")
363
+ assert svc.build_auth_response(approved=False) == {"status": "denied", "contract_version": 1}
364
+ assert svc.build_auth_response(approved=False, reason="timeout")["reason"] == "timeout"
365
+ ```
366
+
367
+ - `tests/ipc/test_capability_channels.py` imports `lightfall.auth.session` at source line ~235 for a logout-teardown test. That test stays in Lightfall (it tests Lightfall's `_wire_session_trust`); delete it from the copy here. Every other test in the file exercises `IPCService` alone and stays.
368
+ - Any test that calls `get_ipc_service` moves back to Lightfall (delete from the copy).
369
+
370
+ - [ ] **Step 5: Hygiene test**
371
+
372
+ ```python
373
+ # tests/ipc/test_no_lightfall_imports.py
374
+ import pathlib
375
+ import re
376
+
377
+ PKG = pathlib.Path(__file__).resolve().parents[2] / "src" / "lightfall_utils" / "ipc"
378
+
379
+
380
+ def test_ipc_package_imports_nothing_from_lightfall():
381
+ offenders = []
382
+ for path in PKG.glob("*.py"):
383
+ for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
384
+ if re.match(r"\s*(from|import)\s+lightfall(\.|\s|$)", line):
385
+ offenders.append(f"{path.name}:{n}: {line.strip()}")
386
+ assert offenders == []
387
+ ```
388
+
389
+ - [ ] **Step 6: Run everything**
390
+
391
+ Run: `.venv/Scripts/python.exe -m pytest tests/ipc -q -p no:cacheprovider`
392
+ Expected: all pass. (`test_discover_peers` and the integration-style tests in `test_actions` start a real `nats-server` via `LocalNatsServer`; `nats-server-bin` is in `dev`.)
393
+
394
+ Run: `.venv/Scripts/python.exe -m ruff check src tests && .venv/Scripts/python.exe -m pyright src/lightfall_utils/ipc`
395
+ Expected: clean.
396
+
397
+ - [ ] **Step 7: Export and commit**
398
+
399
+ `__init__.py` final export list: `IPCService, ActionInfo, EventInfo, TrustDialog, TrustManager, TrustState, LocalNatsServer, LocalNatsServerError, NatsBinaryNotFoundError, NatsPortInUseError, NatsReadinessTimeoutError, resolve_nats_binary, nats_binary_version, probe_nats, BASE_ERROR_CODES, CONTRACT_VERSION, ErrorCodes, error_reply, ok_reply`.
400
+
401
+ ```bash
402
+ git add src/lightfall_utils/ipc tests/ipc
403
+ git commit -m "feat(ipc): move IPCService from Lightfall; app-neutral auth reply"
404
+ ```
405
+
406
+ ---
407
+
408
+ ### Task 5: Docs, README, release
409
+
410
+ **Files:**
411
+ - Modify: `README.md` (feature list + one paragraph), `docs/` (new page `docs/ipc.md` if the docs tree has per-module pages; otherwise a section in the existing API page)
412
+ - Modify: `tests/test_package_hygiene.py` if it enumerates public modules
413
+
414
+ - [ ] **Step 1: Document**
415
+
416
+ In `README.md` add a bullet under the feature list: "**IPC** — NATS-backed `IPCService` with a trust handshake, per-session capability channels, discovery and a structured reply protocol; optional `LocalNatsServer` for broker-less development (`pip install lightfall-utils[local-nats]`)." Add `docs/ipc.md` containing: the subject shapes (`{prefix}.auth.request`, `{prefix}.meta.actions/events`, `{prefix}.session.{token}.<suffix>`, discover), the handshake sequence, `build_auth_response(extra=...)` for app fields, `ErrorCodes` for app vocabularies, and a 20-line "register an action" example (adapted from Lightfall's `ipc-architecture.md` "Registering a New Action", with `ipc.reply(reply, ok_reply(status="ok", ...))`).
417
+
418
+ - [ ] **Step 2: Full suite, tag**
419
+
420
+ Run: `.venv/Scripts/python.exe -m pytest -q -p no:cacheprovider`
421
+ Expected: all green (the existing suites plus `tests/ipc`).
422
+
423
+ ```bash
424
+ git add README.md docs
425
+ git commit -m "docs: lightfall_utils.ipc"
426
+ git tag -a v0.2.0 -m "lightfall-utils 0.2.0: NATS IPC layer extracted from Lightfall"
427
+ ```
428
+
429
+ (Do **not** push; Ron decides when.)