yet-another-agentic-chat 0.1.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.
@@ -0,0 +1,22 @@
1
+ # The Windows job is load-bearing: nobody on the project has Windows hardware, so this matrix is where the
2
+ # selector-loop and bind-election facts in CLAUDE.md graduate from source-verified to measured.
3
+ name: CI
4
+
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ pull_request:
9
+
10
+ jobs:
11
+ test:
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, macos-latest, windows-latest]
16
+ runs-on: ${{ matrix.os }}
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v5
20
+ - run: uv sync --frozen
21
+ - run: uv run ruff check .
22
+ - run: uv run pytest -q
@@ -0,0 +1,20 @@
1
+ # Publishing is never automatic: this runs only when a GitHub release is published, which is a manual act of the
2
+ # maintainer. The trust chain is PyPI's trusted publisher for amyodov/yet-another-agentic-chat, which accepts
3
+ # uploads only from this workflow file running in the `pypi` environment -- OIDC, no token stored anywhere.
4
+ name: Publish
5
+
6
+ on:
7
+ release:
8
+ types: [published]
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ permissions:
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+ - run: uv build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,222 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # Anything with "-nogit" in its name stays local: scratch notes, one-off scripts,
221
+ # briefs, dumps. Name a file that way instead of adding a line here.
222
+ *-nogit*
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,259 @@
1
+ # CLAUDE.md
2
+
3
+ See `README.md` for what YAAC does. This file is about working on it.
4
+
5
+ `README.md` also tracks features — what is in the current version and what is planned. Update its Status section
6
+ whenever you ship or defer something; it is the only record, as there is no separate roadmap or issue tracker.
7
+
8
+ ## Vocabulary
9
+
10
+ | Term | Meaning |
11
+ | --- | --- |
12
+ | **frontend** | The MCP server: tool definitions, no state. |
13
+ | **backend** | In-process module: ZMQ sockets, inbox, roster cache. |
14
+ | **hat** | Whichever backend won the bind. Confers the job of passing messages along, and nothing else. |
15
+ | **participant** | Any backend on a channel, wearing the hat or not. |
16
+ | **membership** | One (channel, name) a process holds. A process may hold several. |
17
+ | **connection id** | A membership's routing id, returned by `join_channel` and used to address it. |
18
+ | **channel** | A named conversation. Addressing, not isolation. |
19
+ | **name** | A user-chosen name within a channel. Raw UTF-8. |
20
+ | **routing_id** | ULID set as the ZMQ `ROUTING_ID`; `zmq_routing_id` on the wire. |
21
+ | **address** | `{name, zmq_routing_id}`. How participants are named on the wire — never a bare string. |
22
+
23
+ ZMQ's own words stay ZMQ's own, prefixed: `zmq_routing_id`, not a YAAC synonym for it.
24
+
25
+ "frontend" and "backend" are relative to the MCP tool surface, not web layers. "channel" always means a YAAC channel,
26
+ never the Claude Code feature of the same name.
27
+
28
+ ## Layout
29
+
30
+ ```
31
+ src/yaac/
32
+ frontend.py MCP tool definitions
33
+ backend.py sockets, bind election, receive loops, connection state
34
+ hat.py routing table, whois, roster, bounce
35
+ protocol.py envelope + control messages, serialization
36
+ ```
37
+
38
+ Python 3.14+, `uv`, `ruff`. Dependencies are `pyzmq` and `mcp` — ask before adding another.
39
+
40
+ To keep a file out of git, put `-nogit` in its name (`notes-nogit.md`, `probe-nogit.py`). `.gitignore` matches
41
+ `*-nogit*`, so don't add per-file entries to it.
42
+
43
+ Line length 120, code and prose alike. Use current syntax freely: `match`/`case`, walrus, PEP 758 `except A, B:`.
44
+ Annotations are lazy in 3.14, so no `from __future__ import annotations`.
45
+
46
+ No nested `if`s where a flat shape exists: prefer `match`/`case` or an `if`/`elif`/`else` chain. Use the `else`
47
+ clause of `try`, `for`, and `while` when it says what the code means — `for`/`else` is exactly "the loop found
48
+ nothing".
49
+
50
+ ## Verified facts
51
+
52
+ Measured here, not assumed. Re-measure before contradicting.
53
+
54
+ **`ROUTER_NOTIFY` is unusable and fails in a way that looks fine.** It is a libzmq draft option. The constant imports,
55
+ so a check for `hasattr(zmq, "ROUTER_NOTIFY")` passes, but `setsockopt` rejects it with `EINVAL` — released wheels
56
+ bundle libzmq built without drafts (`zmq.has('draft')` is `False`). True anywhere `pip install pyzmq` is used.
57
+
58
+ Replacements, both non-draft:
59
+ - **Arrival**: the participant's DEALER monitor fires `EVENT_CONNECTED`, and the participant re-sends `hello`. Covers
60
+ the first connect too, so whoever picks up the hat next is told who is present without asking.
61
+ - **Departure**: `ROUTER_MANDATORY` raises `EHOSTUNREACH` when sending to a routing id that left. Eviction is lazy,
62
+ detected on the first failed send — which is when we want to bounce anyway.
63
+
64
+ **Claude Code honours `tools/list_changed`, but only if the server advertised `tools.listChanged: true`.** The SDK's
65
+ `run_stdio_async()` hardcodes `NotificationOptions()` with every flag false, so `main` runs the low-level server
66
+ directly to override it. Advertising false and then sending the notification looks like a client bug and isn't —
67
+ check what `initialize` claimed first.
68
+
69
+ So the tool list is dynamic: dormant sessions list `list_channels` and `join_channel`, the other five appear on first
70
+ join and go on last leave.
71
+
72
+ **MCP cannot push into an idle session.** Nothing in the server→client set (`notifications/message`,
73
+ `resources/updated`, `list_changed`, `sampling/createMessage`, `elicitation/create`) reaches the model's context.
74
+ Delivery is pull-only, so the tool descriptions have to carry the reminder to call `check_inbox`.
75
+
76
+ **Binding a busy port fails in ~0.4 ms** with `EADDRINUSE`. That is why every backend can just try.
77
+
78
+ **Windows needs the selector event loop** (found in pyzmq `zmq/asyncio.py`; measured since — the CI Windows job runs
79
+ the full suite). `zmq.asyncio` waits on sockets with `loop.add_reader`, which the default `ProactorEventLoop` lacks;
80
+ pyzmq then raises `RuntimeError` at first socket use unless tornado ≥ 6.1 is importable. `main` therefore passes
81
+ `loop_factory=asyncio.SelectorEventLoop` on win32 — `loop_factory`, not `set_event_loop_policy`, because 3.14
82
+ deprecates the policy API. The reverse constraint holds in tests: `asyncio.create_subprocess_exec` is proactor-only
83
+ on Windows, so `conftest.py` picks the loop per test module via the `pytest_asyncio_loop_factories` hook.
84
+
85
+ **The bind election stays single-winner on Windows** (found in libzmq `tcp_listener.cpp`; exercised by the CI
86
+ Windows job). Plain
87
+ `SO_REUSEADDR` there would let a second bind of an actively-bound port succeed; libzmq uses `SO_EXCLUSIVEADDRUSE`
88
+ on Windows instead, so exactly one ROUTER holds the endpoint on every OS.
89
+
90
+ **`CLAUDE_CODE_SESSION_ID` is visible to both the MCP server and hooks**, and equals the `session_id` a hook gets on
91
+ stdin. Better key than the cwd if v1 ever needs an out-of-process reader to find the right session, since it
92
+ separates two sessions in one directory. Absent on Claude Desktop. Nothing uses it today.
93
+
94
+ **Several memberships per process.** `Backend` holds a dict of `Membership`, each with its own routing id, DEALER,
95
+ roster and inbox, so the hat sees them as unrelated participants and the protocol needed no change. This matters
96
+ for clients running one server per application rather than per conversation, such as Claude Desktop — where it also
97
+ means one conversation can address another's connection, so `check_inbox` requires the id rather than guessing.
98
+
99
+ ## Message format
100
+
101
+ The wire format itself is specified in `docs/message-format.md`; this section is the rules for code that touches it.
102
+ Its agreed successor — the envelope system, not yet built — is specified in `docs/zmq.md`.
103
+
104
+ `protocol.dumps` is the only serializer, and it stamps `yaac: PROTOCOL_VERSION` first on every top-level dict. That
105
+ ordering is the point: every message opens with `{"yaac":1`, a magic number a reader can key on without parsing. No
106
+ trailing comma is promised — a message with no other field ends there. `dumps` asserts its own output against
107
+ `MAGIC`, which is written out by hand so the two can be caught drifting apart.
108
+
109
+ Field order is otherwise whatever the `to_wire` constructors build, so keep `body` last in them: it is the only
110
+ unbounded field, and `head -c` on a log should show routing regardless of body size. Equal content gives equal bytes,
111
+ so a message has one identity to hash or sign later. Nothing computes a signature today.
112
+
113
+ `PROTOCOL_VERSION` marks the encoding generation, not the field list. Renaming or adding fields does not bump it;
114
+ replacing JSON with a binary framing would.
115
+
116
+ Received frames go through `protocol.parse`, never `loads` — it rejects a `yaac` field that is not exactly this
117
+ build's version, checking the parsed value rather than the leading bytes, since that is what the format guarantees.
118
+ `type(version) is not int` because `1.0` and `True` both equal `1`. Do not call `json.dumps` anywhere else.
119
+
120
+ `from` and `to` are `Address` objects, not strings: `{name, zmq_routing_id}`, either nullable. A name is unique on a
121
+ channel only while its holder is connected; a routing id identifies one connection and is never reused. Further
122
+ locators can be added as fields without breaking parsers, which is why this is a structure.
123
+
124
+ `Address`, `Destination` and `Envelope` are frozen dataclasses with `to_wire`/`from_wire`. Parse with `from_wire`
125
+ rather than reading dict keys — it rejects a malformed address instead of coercing it.
126
+
127
+ ## Hard rules
128
+
129
+ Each has a test.
130
+
131
+ 1. **Nothing is written to stdout.** It carries the stdio transport; one `print()` breaks the session with a parse
132
+ error. Log to stderr via `backend.log`.
133
+ 2. **Nothing is ever written to disk, and a dormant server opens no socket.** Unread messages, rosters and
134
+ membership all live in memory and die with the process, so there is nothing to clean up after a crash.
135
+ `Backend` is not constructed at all until `join_channel`.
136
+ 3. **`send` never blocks.** A full queue or absent peer must raise. Blocking inside an MCP call freezes the session.
137
+ 4. **Participant and channel names are never parsed, split, validated, or case-folded.** That is why routing uses a
138
+ separate opaque routing id: `ROUTING_ID` has length and byte constraints that user-chosen names must not inherit.
139
+ 5. **The hat never reads a body.** A body that looks like a control message gets delivered, not obeyed.
140
+ 6. **`from` and the sender's channel come from the hat's table**, never from the sender. This is what makes
141
+ cross-channel injection impossible rather than merely forbidden.
142
+
143
+ ## Tests
144
+
145
+ **Parametrize hard.** Minimum number of test functions, maximum behaviour each. If a new test would differ from an
146
+ existing one only in its inputs, add a `parametrize` case instead.
147
+
148
+ **Compare values.** Asserting that something was called, or is truthy, or is non-empty, tests nothing — a stub
149
+ returning plausible shapes would pass.
150
+
151
+ **No second argument to `assert`.** It suppresses pytest's rewritten diff. Explain in a comment on the line above.
152
+
153
+ In `src/` the rule is the opposite: no rewriting, and `-O` strips asserts, so use them only for invariants that
154
+ cannot fail unless this code is wrong — `dumps` checking its own output against `MAGIC` is the case. Anything a peer
155
+ or a malformed frame can trigger must `raise`.
156
+
157
+ **ASCII names in tests** — `ann`, `bob`, `forum`. Non-ASCII appears only as parametrized data where encoding is what
158
+ is being tested. Docs and examples may use any UTF-8; the README's Cyrillic name shows that names are
159
+ unrestricted. Tool descriptions are mostly English but need not be only English.
160
+
161
+ ## Prose
162
+
163
+ These rules cover everything written in words: comments, docstrings, commit messages, docs — and this file.
164
+
165
+ - Say what the mechanism, constraint, or failure mode is, with real names and numbers. Give the *why*; the *what*
166
+ is in the code, and the next reader is as capable as you.
167
+ - Cover intent, not implementation. Text that restates the line below it says nothing and goes stale the first time
168
+ that line changes. If it is clear from the code, do not repeat it.
169
+ - Technical vocabulary is free: "asynchronous queue with exclusive locking" is exactly right. Fancy general words
170
+ are not — "think", not "ponder". No metaphors or slogans; the radio analogy lives in the README, not the source.
171
+ - Write "`ROUTER_MANDATORY` raises `EHOSTUNREACH` for an unknown routing id, so the send fails instead of being
172
+ dropped", not "a stuck queue must fail, not grow silently".
173
+ - Commit messages: the subject names the change, the body gives the reason and what it displaces. Not a list of
174
+ files touched — the diff already shows that.
175
+
176
+ ## Design notes
177
+
178
+ - **The hat is put on by getting there first.** Soft state only, no policy, never configured, and it comes off when the
179
+ process exits.
180
+ - **The hat connects its own DEALER to its own ROUTER.** Costs one socket, and keeps a single send path — no "am I
181
+ the hat" branch anywhere.
182
+ - **Probing must not bind.** If `list_channels` bound, a session that only looked would become hat and drop the
183
+ endpoint when the call returned. Its 10 s timeout is the only exit when nothing is listening, because ZMQ queues
184
+ instead of failing. "Nobody on the air" is a normal answer.
185
+ - **Hold, don't bounce, during a changeover.** A message from an unknown routing id is parked and a `whois` sent, so the
186
+ send succeeds late instead of failing. `pending` is bounded on count and age so a silent peer cannot leak memory.
187
+ - **No heartbeat, no TTL.** An idle net generates zero traffic.
188
+ - **TCP, not `ipc`.** An `ipc://` socket file survives `kill -9` and then blocks bind forever, needing a lock file
189
+ and manual cleanup. The kernel frees a TCP port itself, and libzmq sets `SO_REUSEADDR`.
190
+ - **Port 19116** is `0x4AAC`, below the ephemeral range so the kernel won't hand it out as a source port.
191
+ - **`created` is derived from the roster**, not sent by the hat: channels are deleted when empty, so being alone on
192
+ one means you just made it.
193
+
194
+ ## Decided, not built
195
+
196
+ Settled in discussion with Alex; build only when told, ask before deviating. The envelope system is formalized in
197
+ `docs/zmq.md` — that file is the spec, this section tracks the decisions and their open edges.
198
+
199
+ - **Privacy is convention, not protection.** Everything runs on one machine under one user account, where any
200
+ session can already read another's transcript from disk, so YAAC cannot add a boundary the OS does not have.
201
+ Identity mechanisms exist to prevent accidents and default misuse by well-behaved participants — never to stop a
202
+ determined session, and they must not claim otherwise.
203
+ - **`join_channel` will return a `peer_uid` + `peer_secret` pair.** The secret is an honor-system convention, not
204
+ cryptography: a participant that did not receive it through the proper flow is not that peer. The backend verifies
205
+ it locally against its own memberships; the hat cannot verify anything, since its state is rebuilt from `hello`
206
+ after every changeover. Presenting the pair on join resumes the same peer after a client restart. Still open:
207
+ whether the secret gates `send` and `peers` or only inbox reads, and whether `peer_uid` becomes a locator inside
208
+ `from`/`to`.
209
+ - **The world channel is `None`, not a name.** Omitted, null, or empty `channel` at the tool boundary all mean it,
210
+ and the description says so. Distinguished structurally so no user-chosen string can clash with it and no
211
+ English-centric default name exists. Costs to pay when building: the destination frame currently uses a null
212
+ channel to mean "don't cross-check", `hello` requires a string channel, and `list_channels` needs a row for it.
213
+ - **The tool boundary validates; the protocol never does.** Hard rule 4 stays absolute for the hat and the wire,
214
+ but the MCP layer refuses a completely empty `name` — empty is what an unexpanded template looks like, not a
215
+ choice. Only completely empty: `" "` is not empty, and trimming would be parsing. Rescope rule 4's wording
216
+ when this is built.
217
+ - **A message becomes an object, not a string**: `payload` (any JSON — the tool description must say it may be
218
+ anything; the readers are agents and will adapt), `tags` (topic), `mentions` (who is called on to react — while
219
+ everyone in the delivery scope still hears it). Delivery scope (`to`) and social addressing (`mentions`) are
220
+ separate things: a whisper stays private-scope, and mentioning someone on the open channel is heard by all, like
221
+ radio. There is no urgency mechanism; being mentioned is the attention signal, and any loudness convention is a
222
+ tag.
223
+ - **`from` and `to` are scope objects** whose fields compose: `{channel}` broadcasts to it, `{peer}` whispers,
224
+ `{channel, peer}` is that peer as a member of that channel, and `to: {}` — no scope at all — addresses whoever
225
+ wears the hat, for technical asks; symmetrically `from: {}` marks infrastructure messages such as bounces
226
+ (today `from: null`). Senders never transmit `from` at all — the hat stamps it from its table (rule 6), so
227
+ `from: {}` is unforgeable by construction, not by validation. Open: the world channel is a null channel on the
228
+ wire, so `{}` and `{channel: null}` must not collapse — either strict absent-vs-null discipline in the
229
+ serializer, or a dedicated field for the hat address. Also open: whether the message structure travels as an
230
+ end-to-end body object the hat never decodes, or as envelope fields the hat copies.
231
+ - **One envelope for all wire traffic, control included.** `hello`, channel listing, `whois`, `roster`, bounces
232
+ and chat all travel as the same mail shape; the control/data split by frame count disappears. Rule 5 restates
233
+ as: the hat interprets exactly the mail addressed to `{}`, and nothing else — delivery versus obedience decided
234
+ by addressing, not frame layout. What the receiving backend does with operator mail (roster to cache, bounce to
235
+ inbox) is backend policy, not a second format. Decide at build time whether this framing unification bumps
236
+ `PROTOCOL_VERSION`.
237
+ - **Docs examples use the classical cast** — Alice, Bob, Carol; the hat-sees-everything caveat is "the hat is Eve
238
+ by construction". One side note keeps a non-ASCII name to show names are unrestricted.
239
+
240
+ ## Out of scope
241
+
242
+ Deferred on purpose — ask before adding.
243
+
244
+ - Outbox, SQLite, retries, acks, dedup, store-and-forward
245
+ - Delivery guarantees. v0 may lose messages, as long as it loses them loudly.
246
+ - Multi-host, CURVE auth, namespaced names
247
+ - Direct participant-to-participant connections after discovery
248
+ - Channel UUIDs as anything more than a reported field
249
+ - Presence beyond "who is on this channel now"
250
+ - Threads, reactions, history, shared task lists
251
+
252
+ ## Reference
253
+
254
+ ZeroMQ's **Zyre** (RFC 36 / ZRE) solves nearly this problem: `ENTER`/`EXIT`, `JOIN`/`LEAVE`, `WHISPER`, `SHOUT`, over
255
+ DEALER-ROUTER. Worth reading before changing the protocol. Don't take the dependency — we need a subset, and `pyre`
256
+ is LGPLv3 and tracks Zyre 1.0.
257
+
258
+ Its better idea: separate discovery from data, so peers meet at a rendezvous point and then connect directly. That
259
+ removes the relay bottleneck and the confidentiality caveat in the README. Candidate for later.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Myodov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.