alter-runtime 0.4.8__tar.gz → 0.4.9__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 (63) hide show
  1. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/LICENSE +1 -1
  2. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/PKG-INFO +11 -13
  3. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/README.md +9 -11
  4. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/__init__.py +1 -1
  5. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/adapters/__init__.py +0 -2
  6. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/adapters/claude_jsonl_watcher.py +2 -2
  7. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/cli.py +92 -1
  8. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/clients/token_usage_client.py +3 -3
  9. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/config.py +64 -19
  10. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/consent.py +43 -12
  11. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/daemon.py +17 -16
  12. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/http_auth.py +1 -1
  13. alter_runtime-0.4.9/alter_runtime/messaging.py +147 -0
  14. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/sdk/client.py +1 -1
  15. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/services/systemd/alter-runtime.service.in +3 -3
  16. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/sockets/unix.py +60 -0
  17. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/__init__.py +3 -1
  18. alter_runtime-0.4.9/alter_runtime/subscribers/doctrine_projection.py +678 -0
  19. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/session_presence.py +3 -3
  20. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/update_loop.py +112 -14
  21. alter_runtime-0.4.9/alter_runtime/verified_apply.py +340 -0
  22. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/weave/__init__.py +1 -1
  23. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/weave/resolver.py +3 -5
  24. alter_runtime-0.4.9/hatch_build.py +17 -0
  25. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/pyproject.toml +15 -9
  26. alter_runtime-0.4.8/.gitignore +0 -39
  27. alter_runtime-0.4.8/alter_runtime/adapters/weave_watcher.py +0 -546
  28. alter_runtime-0.4.8/alter_runtime/weave_log.py +0 -437
  29. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/adapters/contract.py +0 -0
  30. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/adapters/git_watcher.py +0 -0
  31. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/adapters/worktree_watcher.py +0 -0
  32. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/cap_cache.py +0 -0
  33. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/clients/__init__.py +0 -0
  34. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/floor_loop.py +0 -0
  35. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/floor_preflight.py +0 -0
  36. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/invocation_signing.py +0 -0
  37. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/notifiers/__init__.py +0 -0
  38. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/notifiers/desktop.py +0 -0
  39. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/sdk/__init__.py +0 -0
  40. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/service_install.py +0 -0
  41. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/services/__init__.py +0 -0
  42. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/services/launchd/com.alter.runtime.plist.in +0 -0
  43. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/sockets/__init__.py +0 -0
  44. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/sockets/dbus.py +0 -0
  45. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/active_sessions_cron_emitter.py +0 -0
  46. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/active_sessions_do_publisher.py +0 -0
  47. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/active_sessions_gc.py +0 -0
  48. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/active_sessions_writer.py +0 -0
  49. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/adapters_writer.py +0 -0
  50. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/agent_frames.py +0 -0
  51. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/attunement_refresher.py +0 -0
  52. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/bus.py +0 -0
  53. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/cache_writer.py +0 -0
  54. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/ceremony_echo.py +0 -0
  55. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/do_sse.py +0 -0
  56. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/ebpf.py +0 -0
  57. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/inbox_writer.py +0 -0
  58. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/mcp_fallback.py +0 -0
  59. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/presence_feed_writer.py +0 -0
  60. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/presence_writer.py +0 -0
  61. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/session_refresher.py +0 -0
  62. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/sse.py +0 -0
  63. {alter_runtime-0.4.8 → alter_runtime-0.4.9}/alter_runtime/subscribers/weave_intent_writer.py +0 -0
@@ -175,7 +175,7 @@
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Copyright 2026 ALTER Meridian Pty Ltd
178
+ Copyright 2026 Alter Meridian Pty Ltd
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alter-runtime
3
- Version: 0.4.8
3
+ Version: 0.4.9
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
7
7
  Project-URL: Repository, https://github.com/true-alter/alter-runtime
8
8
  Project-URL: Bug Tracker, https://github.com/true-alter/alter-runtime/issues
9
9
  Author: ~Alter
10
- Author-email: ALTER Meridian Pty Ltd <hello@truealter.com>
10
+ Author-email: Alter Meridian Pty Ltd <hello@truealter.com>
11
11
  License: Apache-2.0
12
12
  License-File: LICENSE
13
13
  Keywords: agent,ai-agents,alter,cloudflare,durable-object,identity,llm,mcp,model-context-protocol,psychometric,sovereign-identity,verifiable-credentials,x402
@@ -75,10 +75,10 @@ Description-Content-Type: text/markdown
75
75
 
76
76
  ## Alpha release - filesystem key storage
77
77
 
78
- **`0.1.0` is an alpha release.** The public API surface (CLI flags,
78
+ **This is an alpha release.** The public API surface (CLI flags,
79
79
  Unix-socket JSON schema, D-Bus interface, Python SDK signatures) may change in
80
- backwards-incompatible ways before the `0.2.0` stable cut. Pin exact versions in
81
- downstream tooling and read the CHANGELOG before upgrading.
80
+ backwards-incompatible ways before the stable release. Pin exact versions in
81
+ downstream tooling before upgrading.
82
82
 
83
83
  **Private keys are stored on the filesystem, not in an OS keychain.** On first
84
84
  `alter-runtime init`, the device Ed25519 keypair is written to
@@ -88,7 +88,7 @@ owning user and never leaves the device. This behaviour is deliberate for the
88
88
  alpha: it keeps the daemon self-contained on any POSIX host and avoids a hard
89
89
  dependency on distro-specific secret stores during early testing.
90
90
 
91
- **OS-native secret storage is deferred to `0.2.0`:** Linux `libsecret` /
91
+ **OS-native secret storage is deferred to a future release:** Linux `libsecret` /
92
92
  `kwallet`, macOS Keychain, and Windows Credential Manager integration will ship
93
93
  behind a `keystore = "native"` config knob. Filesystem storage will remain the
94
94
  documented fallback.
@@ -100,10 +100,9 @@ attestation). The filesystem-stored device key then scopes only to ambient
100
100
  signal ingestion, not to high-assurance authorisations, which require a fresh
101
101
  hardware passkey ceremony per session.
102
102
 
103
- **Reporting issues.** Security-relevant reports: please follow
104
- [SECURITY.md](./SECURITY.md) and email `security@truealter.com` rather than
105
- filing a public issue. Non-security bugs and feature requests: email
106
- `support@truealter.com`.
103
+ **Reporting issues.** Security-relevant reports: please email
104
+ `security@truealter.com` rather than filing a public issue. Non-security bugs
105
+ and feature requests: email `support@truealter.com`.
107
106
 
108
107
  ## Status
109
108
 
@@ -161,7 +160,7 @@ alter-runtime stop
161
160
  `https://api.truealter.com/api/v1/mcp` when the DO is unreachable for more than 3
162
161
  seconds. Your surfaces never know which path served them.
163
162
 
164
- 3. **Exposes three local transports:**
163
+ 3. **Exposes** three local transports:
165
164
  - **Unix socket** at `/run/user/$UID/alter.sock` (Linux) or
166
165
  `~/Library/Application Support/alter/runtime.sock` (macOS) - line-delimited JSON
167
166
  - **D-Bus** interface `org.alter.Identity1` on the session bus (Linux) - used by
@@ -245,8 +244,7 @@ asyncio.run(main())
245
244
  - **Arch Linux (AUR)** - `alter-runtime`
246
245
  - **Homebrew** - `brew install alter-runtime`
247
246
 
248
- Pin an exact version in downstream tooling during the alpha and read the
249
- CHANGELOG before upgrading.
247
+ Pin an exact version in downstream tooling during the alpha before upgrading.
250
248
 
251
249
  ## Related projects
252
250
 
@@ -7,10 +7,10 @@
7
7
 
8
8
  ## Alpha release - filesystem key storage
9
9
 
10
- **`0.1.0` is an alpha release.** The public API surface (CLI flags,
10
+ **This is an alpha release.** The public API surface (CLI flags,
11
11
  Unix-socket JSON schema, D-Bus interface, Python SDK signatures) may change in
12
- backwards-incompatible ways before the `0.2.0` stable cut. Pin exact versions in
13
- downstream tooling and read the CHANGELOG before upgrading.
12
+ backwards-incompatible ways before the stable release. Pin exact versions in
13
+ downstream tooling before upgrading.
14
14
 
15
15
  **Private keys are stored on the filesystem, not in an OS keychain.** On first
16
16
  `alter-runtime init`, the device Ed25519 keypair is written to
@@ -20,7 +20,7 @@ owning user and never leaves the device. This behaviour is deliberate for the
20
20
  alpha: it keeps the daemon self-contained on any POSIX host and avoids a hard
21
21
  dependency on distro-specific secret stores during early testing.
22
22
 
23
- **OS-native secret storage is deferred to `0.2.0`:** Linux `libsecret` /
23
+ **OS-native secret storage is deferred to a future release:** Linux `libsecret` /
24
24
  `kwallet`, macOS Keychain, and Windows Credential Manager integration will ship
25
25
  behind a `keystore = "native"` config knob. Filesystem storage will remain the
26
26
  documented fallback.
@@ -32,10 +32,9 @@ attestation). The filesystem-stored device key then scopes only to ambient
32
32
  signal ingestion, not to high-assurance authorisations, which require a fresh
33
33
  hardware passkey ceremony per session.
34
34
 
35
- **Reporting issues.** Security-relevant reports: please follow
36
- [SECURITY.md](./SECURITY.md) and email `security@truealter.com` rather than
37
- filing a public issue. Non-security bugs and feature requests: email
38
- `support@truealter.com`.
35
+ **Reporting issues.** Security-relevant reports: please email
36
+ `security@truealter.com` rather than filing a public issue. Non-security bugs
37
+ and feature requests: email `support@truealter.com`.
39
38
 
40
39
  ## Status
41
40
 
@@ -93,7 +92,7 @@ alter-runtime stop
93
92
  `https://api.truealter.com/api/v1/mcp` when the DO is unreachable for more than 3
94
93
  seconds. Your surfaces never know which path served them.
95
94
 
96
- 3. **Exposes three local transports:**
95
+ 3. **Exposes** three local transports:
97
96
  - **Unix socket** at `/run/user/$UID/alter.sock` (Linux) or
98
97
  `~/Library/Application Support/alter/runtime.sock` (macOS) - line-delimited JSON
99
98
  - **D-Bus** interface `org.alter.Identity1` on the session bus (Linux) - used by
@@ -177,8 +176,7 @@ asyncio.run(main())
177
176
  - **Arch Linux (AUR)** - `alter-runtime`
178
177
  - **Homebrew** - `brew install alter-runtime`
179
178
 
180
- Pin an exact version in downstream tooling during the alpha and read the
181
- CHANGELOG before upgrading.
179
+ Pin an exact version in downstream tooling during the alpha before upgrading.
182
180
 
183
181
  ## Related projects
184
182
 
@@ -6,5 +6,5 @@ device touches. See the package README for an overview.
6
6
 
7
7
  from alter_runtime.sdk.client import AlterClient
8
8
 
9
- __version__ = "0.4.8"
9
+ __version__ = "0.4.9"
10
10
  __all__ = ["AlterClient", "__version__"]
@@ -14,12 +14,10 @@ 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.weave_watcher import WeaveWatcher
18
17
  from alter_runtime.adapters.worktree_watcher import WorktreeWatcher
19
18
 
20
19
  __all__ = [
21
20
  "ClaudeJsonlWatcher",
22
21
  "GitWatcher",
23
- "WeaveWatcher",
24
22
  "WorktreeWatcher",
25
23
  ]
@@ -4,7 +4,7 @@ Watches the per-project ``*.jsonl`` transcript files under Claude Code's
4
4
  home projects directory with ``watchdog``.
5
5
  On modification, reads new lines from the persisted byte offset, parses each
6
6
  via the privacy-hard-stop ``parse_assistant_line`` function, and POSTs
7
- batches to the ALTER backend token-usage audit endpoint.
7
+ batches to the ~alter backend token-usage audit endpoint.
8
8
 
9
9
  Privacy guarantee
10
10
  -----------------
@@ -123,7 +123,7 @@ def parse_assistant_line(record: dict) -> Optional[dict]:
123
123
  """Parse one JSONL record and return a token-usage dict, or None.
124
124
 
125
125
  This is the privacy hard-stop between Claude Code's local transcripts
126
- and ALTER's audit database. It is a strict whitelist: the output dict is
126
+ and ~alter's audit database. It is a strict whitelist: the output dict is
127
127
  constructed key-by-key from explicitly named fields. ``record``,
128
128
  ``message``, and ``usage`` are NEVER spread. No key beyond the whitelist
129
129
  can reach the output.
@@ -9,6 +9,7 @@ Subcommands:
9
9
  daemon Run the supervisor in the foreground (for systemd Type=exec or debugging).
10
10
  query Query the current materialised view (attunement, warmth, trust tier).
11
11
  ingest Manually ingest a signal (for testing).
12
+ send Send a member-to-member message via the running daemon.
12
13
 
13
14
  Matches the shape of the companion CLI's command handler - a simple switch-on-verb,
14
15
  no external CLI framework dependency (argparse is stdlib).
@@ -139,6 +140,20 @@ def build_parser() -> argparse.ArgumentParser:
139
140
  )
140
141
  p_ingest.add_argument("--producer", default="cli:manual", help="Producer identifier.")
141
142
 
143
+ # send
144
+ p_send = sub.add_parser(
145
+ "send",
146
+ help="Send a member-to-member message via the running daemon.",
147
+ )
148
+ p_send.add_argument("to", help="Recipient ~handle (e.g. ~peer).")
149
+ p_send.add_argument("body", help="Message body, markdown, <= 8 KiB.")
150
+ p_send.add_argument(
151
+ "--content-type",
152
+ dest="content_type",
153
+ default=None,
154
+ help="Message content_type (default: text/markdown).",
155
+ )
156
+
142
157
  return parser
143
158
 
144
159
 
@@ -232,7 +247,7 @@ def cmd_start(args: argparse.Namespace) -> int:
232
247
  except UserDeletedArtefact as exc:
233
248
  print(
234
249
  f" {exc.args[0]} was previously deleted by the user.\n"
235
- " ALTER will not recreate it automatically.\n"
250
+ " ~alter will not recreate it automatically.\n"
236
251
  " To reinstall, run: alter-runtime start --reinstall",
237
252
  file=sys.stderr,
238
253
  )
@@ -448,6 +463,81 @@ def cmd_ingest(args: argparse.Namespace) -> int:
448
463
  return 0
449
464
 
450
465
 
466
+ def _daemon_token() -> str | None:
467
+ """Read the daemon auth token written beside the socket, or None."""
468
+ token_file = unix_socket_path().parent / "alter-daemon-token"
469
+ try:
470
+ return token_file.read_text(encoding="utf-8").strip() or None
471
+ except OSError:
472
+ return None
473
+
474
+
475
+ async def _socket_request(request: dict[str, object]) -> dict[str, object]:
476
+ """Connect to the daemon socket, authenticate, send one request, return the reply.
477
+
478
+ Mirrors the same auth handshake the local MCP bridge performs: read the
479
+ daemon token file, present it via ``{"method":"auth",...}``, then send the
480
+ request line and read one JSON reply line.
481
+ """
482
+ socket = unix_socket_path()
483
+ reader, writer = await asyncio.open_unix_connection(str(socket))
484
+ try:
485
+ token = _daemon_token()
486
+ if token:
487
+ writer.write((json.dumps({"method": "auth", "token": token}) + "\n").encode("utf-8"))
488
+ await writer.drain()
489
+ auth_line = await asyncio.wait_for(reader.readline(), timeout=5.0)
490
+ auth_resp = json.loads(auth_line or b"{}")
491
+ if not auth_resp.get("ok"):
492
+ return {"ok": False, "error": f"auth failed: {auth_resp.get('error', 'unknown')}"}
493
+ writer.write((json.dumps(request) + "\n").encode("utf-8"))
494
+ await writer.drain()
495
+ # A send does a backend Durable-Object round-trip that can exceed a
496
+ # minute; wait above the daemon's own send timeout so the CLI does not
497
+ # give up before the daemon answers.
498
+ line = await asyncio.wait_for(reader.readline(), timeout=90.0)
499
+ if not line:
500
+ return {"ok": False, "error": "no response from daemon"}
501
+ reply = json.loads(line)
502
+ if not isinstance(reply, dict):
503
+ return {"ok": False, "error": "malformed reply from daemon"}
504
+ return reply
505
+ finally:
506
+ writer.close()
507
+ with contextlib.suppress(Exception):
508
+ await writer.wait_closed()
509
+
510
+
511
+ def cmd_send(args: argparse.Namespace) -> int:
512
+ """Send a member-to-member message via the running daemon.
513
+
514
+ The daemon performs the authenticated send (it holds the session bearer
515
+ and signing key); this verb is a thin socket client. Default-closed: the
516
+ recipient must have granted you messaging permission first.
517
+ """
518
+ socket = unix_socket_path()
519
+ if not socket.exists():
520
+ print(
521
+ "daemon socket absent - is alter-runtime running? (`alter-runtime status`)",
522
+ file=sys.stderr,
523
+ )
524
+ return 1
525
+ request: dict[str, object] = {"method": "send", "to": args.to, "body": args.body}
526
+ if args.content_type:
527
+ request["content_type"] = args.content_type
528
+ try:
529
+ reply = asyncio.run(_socket_request(request))
530
+ except (OSError, ConnectionError) as exc:
531
+ print(f"failed to reach daemon socket: {exc}", file=sys.stderr)
532
+ return 1
533
+ if reply.get("ok"):
534
+ mid = reply.get("message_id") or reply.get("id") or ""
535
+ print(f"sent to {args.to}" + (f" (message {mid})" if mid else ""))
536
+ return 0
537
+ print(f"send failed: {reply.get('error', 'unknown error')}", file=sys.stderr)
538
+ return 1
539
+
540
+
451
541
  # ---------------------------------------------------------------------------
452
542
  # Main dispatcher
453
543
  # ---------------------------------------------------------------------------
@@ -461,6 +551,7 @@ COMMANDS: dict[str, object] = {
461
551
  "daemon": cmd_daemon,
462
552
  "query": cmd_query,
463
553
  "ingest": cmd_ingest,
554
+ "send": cmd_send,
464
555
  }
465
556
 
466
557
 
@@ -1,6 +1,6 @@
1
1
  """TokenUsageClient - authenticated HTTP client for the token-usage audit endpoint.
2
2
 
3
- Posts batches of parsed token-usage events to the ALTER backend.
3
+ Posts batches of parsed token-usage events to the ~alter backend.
4
4
 
5
5
  Authentication
6
6
  --------------
@@ -101,12 +101,12 @@ def _derive_host_id() -> str:
101
101
 
102
102
 
103
103
  class TokenUsageClient:
104
- """Async client for the ALTER backend token-usage audit endpoint.
104
+ """Async client for the ~alter backend token-usage audit endpoint.
105
105
 
106
106
  Parameters
107
107
  ----------
108
108
  base_url:
109
- Base URL of the ALTER backend API. Reads ``ALTER_RUNTIME_API_BASE``
109
+ Base URL of the ~alter backend API. Reads ``ALTER_RUNTIME_API_BASE``
110
110
  env var at construction if not explicitly provided; defaults to
111
111
  ``https://api.truealter.com``.
112
112
  jwt_provider:
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "cache_dir",
44
44
  "config_dir",
45
45
  "data_dir",
46
+ "doctrine_projection_dir",
46
47
  "enc_keyfile_path",
47
48
  "enc_session_path",
48
49
  "keypair_path",
@@ -111,6 +112,24 @@ def runtime_state_dir() -> Path:
111
112
  return path
112
113
 
113
114
 
115
+ def doctrine_projection_dir() -> Path:
116
+ """Return $XDG_DATA_HOME/alter/doctrine (default ~/.local/share/alter/doctrine).
117
+
118
+ The canonical home of the local read-only doctrine projection:
119
+ ``personal.jsonl`` / ``collective.jsonl`` plus their per-scope checkpoint
120
+ files live here. Note the ``alter/`` namespace (NOT ``alter-runtime/``):
121
+ the projection is pinned under the broad ``alter`` data tree alongside the
122
+ CLI's other ``alter``-namespaced data, not the daemon's private
123
+ ``alter-runtime`` subtree.
124
+
125
+ Created on first access with mode ``0o700`` so the projected doctrine (which
126
+ may carry personal-scope notes) cannot be world-read.
127
+ """
128
+ path = _xdg_path("XDG_DATA_HOME", ".local/share") / "alter" / "doctrine"
129
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
130
+ return path
131
+
132
+
114
133
  def keypair_path() -> Path:
115
134
  """Path to the device Ed25519 keypair (generated by `alter-runtime init`)."""
116
135
  return config_dir() / "keypair.json"
@@ -173,6 +192,18 @@ class Session:
173
192
  #: ``handle_mismatch``. ``None`` on legacy sessions (legacy fallback:
174
193
  #: attempt to sign and let the server surface any mismatch).
175
194
  signing_key_fingerprint: str | None = None
195
+ #: Long-lived member API key written by ``alter login``. This is the
196
+ #: credential the bridge sends as ``X-ALTER-API-Key`` for member-self tools
197
+ #: (doctrine, messaging, alignment, consent, attunement) - those tools
198
+ #: require ``member_self`` scope, which the 24h bearer JWT does NOT carry.
199
+ #: The doctrine-projection poller authenticates with this key; ``None`` when
200
+ #: the session predates the member-key cutover (the poller then soft-fails
201
+ #: the doctrine read and logs, never crashes).
202
+ member_api_key: str | None = None
203
+ #: The member's UUID (assessment-DB ``members.id``). Carried for parity with
204
+ #: the bridge; the backend resolves ownership from the validated credential,
205
+ #: not from this value. ``None`` on legacy sessions.
206
+ member_id: str | None = None
176
207
 
177
208
 
178
209
  class SessionRef:
@@ -423,7 +454,7 @@ def _read_enc_store() -> str | None:
423
454
 
424
455
 
425
456
  def _parse_consent_tier(value: Any) -> int:
426
- """Normalise ``consent_tier`` to the integer ALTER uses internally.
457
+ """Normalise ``consent_tier`` to the integer ~alter uses internally.
427
458
 
428
459
  The CLI writes the consent tier as a string label
429
460
  (``"L1"``, ``"L2"``, ``"L3"``, ``"L4"``) - see the SDK type
@@ -488,6 +519,8 @@ def _parse_session_text(raw_text: str) -> Session | None:
488
519
  logged_in_at=raw.get("logged_in_at"),
489
520
  signing_kid=raw.get("signing_kid") or None,
490
521
  signing_key_fingerprint=raw.get("signing_key_fingerprint") or None,
522
+ member_api_key=raw.get("member_api_key") or None,
523
+ member_id=raw.get("member_id") or None,
491
524
  )
492
525
  except (KeyError, TypeError):
493
526
  return None
@@ -788,16 +821,6 @@ class DaemonConfig:
788
821
  #: ``ALTER_RUNTIME_CLAUDE_JSONL_WATCHER=1`` or direct config mutation.
789
822
  enable_claude_jsonl_watcher: bool = False
790
823
 
791
- # --- Working-tree write-event watcher --------------------------------
792
- #: When ``True``, register :class:`WeaveWatcher` as a supervised
793
- #: Component. It appends every observed working-tree file write to the
794
- #: durable, append-only WriteEvent log (``alter_runtime.weave_log``).
795
- #: Consent-gated: opt-in (default ``False``) because a durable per-handle
796
- #: write log is more sensitive than the short-lived, expiring intent
797
- #: coordination stream. Enable via ``ALTER_RUNTIME_WEAVE_WATCHER=1`` or
798
- #: direct config mutation.
799
- enable_weave_watcher: bool = False
800
-
801
824
  # --- Hardening (frame-signature enforcement) --------------
802
825
  #: When ``True``, every SSE frame received by :class:`DoSseSubscriber`
803
826
  #: MUST carry a valid Ed25519 signature over the canonical (JCS-style
@@ -858,6 +881,24 @@ class DaemonConfig:
858
881
  #: ``ALTER_RUNTIME_AUTOUPDATE_POLL_INTERVAL_SECONDS``.
859
882
  autoupdate_poll_interval_seconds: int = 24 * 60 * 60
860
883
 
884
+ # --- Doctrine projection poller --------------------------------------
885
+ #: Master toggle for :class:`DoctrineProjectionPoller`. When enabled (the
886
+ #: default) the daemon maintains a local read-only JSONL projection of the
887
+ #: member's doctrine substrate per scope (``personal`` / ``collective``)
888
+ #: under :func:`doctrine_projection_dir`. Pull-mode (the backend emits no
889
+ #: event on a doctrine write): the poller calls ``alter_doctrine``'s cheap
890
+ #: ``summary`` verb each tick and only pulls the ``list`` delta when the
891
+ #: etag changes. It shares the read-side MCP wire contract (envelope +
892
+ #: ``mcp_fallback_endpoint`` + Bearer JWT) with the other pollers. Disable
893
+ #: via ``ALTER_RUNTIME_DOCTRINE_PROJECTION=0`` (e.g. air-gapped fleets that
894
+ #: don't want the periodic authenticated round-trip).
895
+ doctrine_projection_enabled: bool = True
896
+ #: Cadence in seconds for the doctrine summary poll. 60s is deliberately
897
+ #: slow: doctrine is a low-velocity living-state surface and the summary
898
+ #: call is cheap, so a steadily-fresh cache (not a tight loop) is the goal.
899
+ #: Override via ``ALTER_RUNTIME_DOCTRINE_PROJECTION_INTERVAL_SECONDS``.
900
+ doctrine_projection_interval_seconds: float = 60.0
901
+
861
902
  @classmethod
862
903
  def load(cls) -> DaemonConfig:
863
904
  """Load config from environment + YAML file + defaults.
@@ -1000,6 +1041,18 @@ class DaemonConfig:
1000
1041
  except ValueError:
1001
1042
  pass
1002
1043
 
1044
+ # --- Doctrine projection poller ----------------------------------
1045
+ _dp_flag = os.environ.get("ALTER_RUNTIME_DOCTRINE_PROJECTION", "").lower()
1046
+ if _dp_flag in ("0", "false", "no", "off"):
1047
+ config.doctrine_projection_enabled = False
1048
+ elif _dp_flag in ("1", "true", "yes", "on"):
1049
+ config.doctrine_projection_enabled = True
1050
+ if dp_interval := os.environ.get("ALTER_RUNTIME_DOCTRINE_PROJECTION_INTERVAL_SECONDS"):
1051
+ try:
1052
+ config.doctrine_projection_interval_seconds = float(dp_interval)
1053
+ except ValueError:
1054
+ pass # Ignore malformed override; default stands.
1055
+
1003
1056
  if level := os.environ.get("ALTER_RUNTIME_LOG_LEVEL"):
1004
1057
  config.log_level = level
1005
1058
  if socket := os.environ.get("ALTER_RUNTIME_SOCKET"):
@@ -1009,14 +1062,6 @@ class DaemonConfig:
1009
1062
  config.enable_claude_jsonl_watcher = True
1010
1063
  elif _cjw_flag in ("0", "false", "no", "off"):
1011
1064
  config.enable_claude_jsonl_watcher = False
1012
- # Working-tree write-event watcher. Consent-gated opt-in: the watcher
1013
- # only writes a local append-only log and never touches frame-signature
1014
- # enforcement, so a plain env toggle is safe.
1015
- _weave_flag = os.environ.get("ALTER_RUNTIME_WEAVE_WATCHER", "").lower()
1016
- if _weave_flag in ("1", "true", "yes", "on"):
1017
- config.enable_weave_watcher = True
1018
- elif _weave_flag in ("0", "false", "no", "off"):
1019
- config.enable_weave_watcher = False
1020
1065
  # Post-pentest-2026-04-26: default is True. Env var still toggles -
1021
1066
  # explicit "0"/"false"/"no"/"off" disables (migration-window opt-out),
1022
1067
  # everything else (including unset) keeps the default True.
@@ -18,7 +18,7 @@ Implements the following install-transparency countermeasures:
18
18
  tombstoned, the install is refused with an actionable message. The user
19
19
  can explicitly clear the tombstone with ``--reinstall``.
20
20
 
21
- ALTER does not use a silent-install pattern.
21
+ ~Alter does not use a silent-install pattern.
22
22
  """
23
23
 
24
24
  from __future__ import annotations
@@ -88,7 +88,11 @@ def _sha256_file(path: Path) -> str:
88
88
  # ---------------------------------------------------------------------------
89
89
 
90
90
 
91
- def enumerate_artefacts(binary_path: Path | None = None) -> list[dict[str, Any]]:
91
+ def enumerate_artefacts(
92
+ binary_path: Path | None = None,
93
+ *,
94
+ binary_sigstore_bundle_url: str | None = None,
95
+ ) -> list[dict[str, Any]]:
92
96
  """Return a list of artefacts that ``alter-runtime start`` will write.
93
97
 
94
98
  Each entry is a dict::
@@ -102,13 +106,21 @@ def enumerate_artefacts(binary_path: Path | None = None) -> list[dict[str, Any]]
102
106
 
103
107
  For v1 the set is:
104
108
 
105
- * The systemd user unit *or* launchd plist (platform-appropriate).
109
+ * The systemd user unit *or* launchd plist (platform-appropriate). This is
110
+ a locally-rendered template, not a signed release artefact, so its
111
+ ``sigstore_bundle_url`` is always ``None``.
106
112
  * The ``alter-runtime`` binary itself (resolved via
107
113
  :func:`~alter_runtime.service_install.resolve_runtime_binary`, with
108
114
  a graceful fallback to ``None`` if the binary is not yet installed).
109
115
 
110
- ``sigstore_bundle_url`` is ``None`` in v1; the bundle-URL lookup lands
111
- in a future release once the static binary build is in place.
116
+ Parameters
117
+ ----------
118
+ binary_sigstore_bundle_url:
119
+ The sigstore bundle URL for the runtime binary, read from the release
120
+ manifest entry for the running platform. When supplied it is stamped
121
+ onto the binary artefact so the consent screen and the post-install
122
+ manifest record the signature provenance. ``None`` (the default) means
123
+ no bundle is known for this install path, e.g. a local source build.
112
124
  """
113
125
  from alter_runtime.service_install import (
114
126
  current_platform,
@@ -154,7 +166,7 @@ def enumerate_artefacts(binary_path: Path | None = None) -> list[dict[str, Any]]
154
166
  "path": resolved_binary,
155
167
  "size_bytes": bin_size,
156
168
  "sha256": _sha256_file(resolved_binary),
157
- "sigstore_bundle_url": None,
169
+ "sigstore_bundle_url": binary_sigstore_bundle_url,
158
170
  }
159
171
  )
160
172
 
@@ -177,7 +189,12 @@ def _human_size(size_bytes: int | None) -> str:
177
189
  return f"{size_bytes / (1024 * 1024):.1f} MiB"
178
190
 
179
191
 
180
- def print_consent_screen(artefacts: list[dict[str, Any]], uninstall_cmd: str) -> None:
192
+ def print_consent_screen(
193
+ artefacts: list[dict[str, Any]],
194
+ uninstall_cmd: str,
195
+ *,
196
+ verified: bool = False,
197
+ ) -> None:
181
198
  """Render the pre-install consent screen to stdout.
182
199
 
183
200
  Format::
@@ -189,24 +206,38 @@ def print_consent_screen(artefacts: list[dict[str, Any]], uninstall_cmd: str) ->
189
206
  ...
190
207
  Sigstore-keyless verification: UNVERIFIED
191
208
  To remove everything: alter-runtime stop
209
+
210
+ Parameters
211
+ ----------
212
+ verified:
213
+ Whether an actual sigstore-verified verdict has been obtained for the
214
+ release artefacts (e.g. from ``alter runtime verify`` / the
215
+ verified-apply path). The headline status reads ``VERIFIED`` ONLY when
216
+ this is ``True``; it is ``UNVERIFIED`` otherwise. A bundle URL being
217
+ merely *present* on an artefact is no longer treated as proof of
218
+ verification: an unverified install must never claim it passed.
192
219
  """
193
220
  print("ALTER Runtime - install consent")
194
221
  print("================================")
195
222
  print("The following artefacts will be written to your system:")
196
223
 
197
- any_sigstore = False
198
224
  for artefact in artefacts:
199
225
  path = artefact["path"]
200
226
  size_str = _human_size(artefact.get("size_bytes"))
201
227
  sha = artefact.get("sha256") or ""
202
228
  short_sha = sha[:12] if sha else "(not yet on disk)"
203
229
  bundle_url = artefact.get("sigstore_bundle_url")
204
- sig_label = "verified" if bundle_url else "unverified"
205
- if bundle_url:
206
- any_sigstore = True
230
+ # Per-artefact label is driven by the verdict, not URL presence: a
231
+ # bundle URL with no verified verdict is "pending", never "verified".
232
+ if verified and bundle_url:
233
+ sig_label = "verified"
234
+ elif bundle_url:
235
+ sig_label = "pending"
236
+ else:
237
+ sig_label = "unverified"
207
238
  print(f" {path} ({size_str}) sha256:{short_sha} sigstore: {sig_label}")
208
239
 
209
- sig_status = "PASSED" if any_sigstore else "UNVERIFIED"
240
+ sig_status = "VERIFIED" if verified else "UNVERIFIED"
210
241
  print(f"Sigstore-keyless verification: {sig_status}")
211
242
  print(f"To remove everything: {uninstall_cmd}")
212
243
  print()
@@ -309,6 +309,7 @@ async def run_daemon(config: DaemonConfig | None = None) -> None:
309
309
  AttunementRefresher,
310
310
  CacheWriter,
311
311
  CeremonyEchoWriter,
312
+ DoctrineProjectionPoller,
312
313
  DoSseSubscriber,
313
314
  EventBus,
314
315
  InboxWriter,
@@ -352,6 +353,20 @@ async def run_daemon(config: DaemonConfig | None = None) -> None:
352
353
  if config.attunement_refresh_enabled:
353
354
  supervisor.register(AttunementRefresher(config, session, event_bus))
354
355
 
356
+ # DoctrineProjectionPoller maintains a local read-only JSONL projection
357
+ # of the member's doctrine substrate per scope (personal/collective)
358
+ # under ~/.local/share/alter/doctrine. Pull-mode: the backend emits no
359
+ # event on a doctrine write, so it polls the cheap ``alter_doctrine``
360
+ # ``summary`` verb each tick and only pulls the ``list`` delta when the
361
+ # etag changes. Shares the read-side MCP wire contract (envelope +
362
+ # endpoint + JWT) with the fallback / attunement pollers; it does NOT
363
+ # touch do_sse's connect/auth/mint path. The projection file is a
364
+ # rolling, rebuildable-from-substrate cache. Pass the session_ref holder
365
+ # when available so the poller reads the live JWT after a proactive
366
+ # rotation. Disable via ALTER_RUNTIME_DOCTRINE_PROJECTION=0.
367
+ if config.doctrine_projection_enabled:
368
+ supervisor.register(DoctrineProjectionPoller(config, session_ref or session))
369
+
355
370
  # SessionRefresher proactively shells ``alter creds refresh --force``
356
371
  # a configurable lead time before the access JWT expires. After a
357
372
  # successful rotation it updates the shared ``session_ref`` holder so
@@ -485,8 +500,7 @@ async def run_daemon(config: DaemonConfig | None = None) -> None:
485
500
 
486
501
  # WorktreeWatcher: working-tree filesystem watcher. Observes the
487
502
  # repo working tree and publishes local.signal kind=worktree_edit
488
- # on every file save, covering editors that lack a hook as a coarse
489
- # second producer for the write log.
503
+ # on every file save, covering editors that lack a hook.
490
504
  supervisor.register(WorktreeWatcher(config, event_bus))
491
505
 
492
506
  # WeaveIntentWriter: subscribes to local.signal worktree_edit events
@@ -499,26 +513,13 @@ async def run_daemon(config: DaemonConfig | None = None) -> None:
499
513
 
500
514
  # Claude Code JSONL token-usage watcher - opt-in via config flag.
501
515
  # Tails the Claude Code projects transcripts and posts token-usage events to
502
- # the ALTER backend audit endpoint. Disabled by default; enable via
516
+ # the ~alter backend audit endpoint. Disabled by default; enable via
503
517
  # ALTER_RUNTIME_CLAUDE_JSONL_WATCHER=1 or direct config mutation.
504
518
  if config.enable_claude_jsonl_watcher:
505
519
  from alter_runtime.adapters.claude_jsonl_watcher import ClaudeJsonlWatcher
506
520
 
507
521
  supervisor.register(ClaudeJsonlWatcher(config, event_bus))
508
522
 
509
- # Working-tree write-log watcher: opt-in via config flag or
510
- # ALTER_RUNTIME_WEAVE_WATCHER=1. Observes working-tree
511
- # IN_CLOSE_WRITE events into the durable, append-only write log
512
- # (~/.local/share/alter-runtime/weave-log.jsonl) that the
513
- # read-back MCP surface reads back. No TTL, no rotation-to-deletion.
514
- # The TTL-bounded weave-intent.jsonl above is the separate
515
- # write-log plane. The write log is an append-only overlay on git,
516
- # never a replacement.
517
- if config.enable_weave_watcher:
518
- from alter_runtime.adapters import WeaveWatcher
519
-
520
- supervisor.register(WeaveWatcher(config, event_bus))
521
-
522
523
  # --- External adapter plugins (entry-point discovery) -------------
523
524
  # Out-of-tree adapters register under the ``alter_runtime.adapters``
524
525
  # entry-point group and are discovered here rather than imported by
@@ -1,4 +1,4 @@
1
- """HTTP header helpers for upstream calls into the ALTER backend.
1
+ """HTTP header helpers for upstream calls into the ~alter backend.
2
2
 
3
3
  Every authenticated request carries an ``Authorization: Bearer <jwt>``
4
4
  header set explicitly by the calling subscriber; that member bearer is