aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-03-08 21:54:30 -0700
committerbndw <ben@bdw.to>2026-03-08 21:54:30 -0700
commite3ac28b9f829c54f88db20245ad58b7d64629d19 (patch)
tree4180f05286a7545b5e2bc9d5379b0ebb2a6ec1ff
initial: Axon protocol spec and README
-rw-r--r--PROTOCOL.md355
-rw-r--r--README.md29
2 files changed, 384 insertions, 0 deletions
diff --git a/PROTOCOL.md b/PROTOCOL.md
new file mode 100644
index 0000000..145c933
--- /dev/null
+++ b/PROTOCOL.md
@@ -0,0 +1,355 @@
1# Axon Protocol — High Level Design
2
3> A Nostr-inspired event relay protocol for AI agent infrastructure. Retains the core architectural insight — signed events, relay as message bus, filtered subscriptions — while cleaning up the crypto, encoding, and type system.
4
5---
6
7## Core Insight
8
9The relay is **Kafka at the edge, plus identity**. It is a log, not a database. It routes signed events between clients and stores them for replay. It is structurally incapable of understanding content it was not designed to index.
10
11---
12
13## Architecture
14
15```
16[client A] ──publish──▶ [relay] ──fanout──▶ [client B]
17
18 [index] ← id, pubkey, kind, created_at, tags
19 [store] ← raw msgpack bytes (opaque)
20```
21
22Consumers (agents, indexers, report jobs) subscribe to the relay and maintain their own materialized views. The relay never aggregates, summarizes, or transforms content. Derived data is always downstream.
23
24---
25
26## Event Structure
27
28```
29Event {
30 id bytes // 32 bytes, SHA256 of canonical signing payload
31 pubkey bytes // 32 bytes, Ed25519 public key
32 created_at int64 // unix timestamp
33 kind uint16 // see Event Kinds registry
34 content bytes // opaque to the relay; msgpack bin type, no UTF-8 assumption
35 sig bytes // 64 bytes, Ed25519 signature over id
36 tags []Tag
37}
38
39Tag {
40 name string
41 values []string
42}
43```
44
45### Signing
46
47The event ID is the `SHA256` of a canonical byte payload. All integers are big-endian. All strings are UTF-8. `||` denotes concatenation.
48
49```
50id = SHA256(canonical_payload)
51sig = ed25519.Sign(privkey, id)
52```
53
54**canonical_payload:**
55
56| Field | Encoding |
57|---|---|
58| pubkey | `uint16(32)` \|\| 32 bytes |
59| created_at | `uint64` |
60| kind | `uint16` |
61| content | `uint32(len)` \|\| UTF-8 bytes |
62| tags | see below |
63
64**canonical_tags:**
65
66Tags are sorted by `name` lexicographically (byte order). For ties on `name`, sort by first value lexicographically. Two tags sharing the same `name` and same first value is a **protocol error** — the relay must reject the event with `400`. Tags are effectively keyed on (name, first value); duplicates are a bug or an attack.
67
68```
69uint16(num_tags)
70for each tag (in sorted order):
71 uint16(len(name)) || utf8(name)
72 uint16(num_values)
73 for each value:
74 uint32(len(value)) || utf8(value)
75```
76
77The `tags` field in `canonical_payload` is `SHA256(canonical_tags)` — a fixed 32-byte commitment regardless of tag count. Implementations may cache this hash to avoid re-sorting on repeated signature verification.
78
79**Full canonical_payload byte layout:**
80
81```
82[0:2] uint16 = 32 pubkey length — always 32 for Ed25519; validate and reject if not 32;
83 reserved for future key types
84[2:34] bytes pubkey
85[34:42] uint64 created_at
86[42:44] uint16 kind
87[44:48] uint32 content length — wire format supports up to ~4GB but relay enforces
88 a maximum of 65536 bytes (64KB); larger events are rejected with 413
89[48:48+n] bytes content (n bytes, n ≤ 65536)
90[48+n:80+n] bytes SHA256(canonical_tags), 32 bytes
91```
92
93Two implementations that agree on this layout will always produce the same `id` for the same event.
94
95---
96
97## Crypto
98
99| Purpose | Algorithm | Go package |
100|---|---|---|
101| Signing | Ed25519 | `crypto/ed25519` (stdlib) |
102| Key exchange | X25519 | `golang.org/x/crypto/curve25519` |
103| Encryption | ChaCha20-Poly1305 | `golang.org/x/crypto/chacha20poly1305` |
104| Hashing / event ID | SHA-256 | `crypto/sha256` (stdlib) |
105
106All dependencies are from the Go standard library or `golang.org/x/crypto`. No third-party crypto. Ed25519 keys are converted to X25519 for ECDH — one keypair serves both signing and encryption. ChaCha20-Poly1305 provides authenticated encryption (AEAD); the ciphertext cannot be tampered with without detection.
107
108---
109
110## Wire Format
111
112**Transport:** WebSocket (binary frames)
113**Serialization:** MessagePack
114
115MessagePack is binary JSON — identical data model, no schema, no codegen. Binary fields (`id`, `pubkey`, `sig`) are raw bytes on the wire, eliminating base64 encoding and simplifying the signing story.
116
117### Connection Authentication
118
119Authentication happens immediately on connect before any other messages are accepted.
120
121```
122relay → Challenge { nonce: bytes } // 32 random bytes
123client → Auth { pubkey: bytes, sig: bytes }
124relay → Ok { message: string } // or Error then close
125```
126
127The client signs over `nonce || relay_url` to prevent replay to a different relay:
128
129```
130sig = ed25519.Sign(privkey, SHA256(nonce || utf8(relay_url)))
131```
132
133The relay verifies the signature then checks the pubkey against its allowlist. Failures return `Error { code: 401 }` and close the connection.
134
135**Allowlist:** the relay maintains a set of authorized pubkeys in config or the local database. Publish and subscribe are both gated on allowlist membership. Adding a user means adding their pubkey — no passwords, no tokens, no certificate infrastructure.
136
137### Client → Relay
138
139```
140Auth { pubkey: bytes, sig: bytes }
141Subscribe { sub_id: string, filter: Filter }
142Unsubscribe { sub_id: string }
143Publish { event: Event }
144```
145
146### Relay → Client
147
148```
149Challenge { nonce: bytes }
150EventEnvelope { sub_id: string, event: Event }
151Eose { sub_id: string }
152Ok { message: string }
153Error { code: uint16, message: string }
154```
155
156Each message is a msgpack array: `[message_type, payload]` where `message_type` is a uint16.
157
158### Error Codes
159
160HTTP status codes, reused for familiarity.
161
162| Code | Meaning |
163|---|---|
164| 400 | Bad request (malformed message, invalid signature) |
165| 401 | Not authenticated |
166| 403 | Not authorized (pubkey not in allowlist) |
167| 409 | Duplicate event |
168| 413 | Message too large |
169
170The relay sends `Error` and keeps the connection open for recoverable conditions (e.g. a bad publish). For unrecoverable conditions (e.g. auth failure) it sends `Error` then closes.
171
172### Keepalive
173
174The relay sends a WebSocket ping every **30 seconds**. Clients must respond with a pong. Connections that miss two consecutive pings (60 seconds) are closed. Clients may also send pings; the relay will pong.
175
176---
177
178## Filters
179
180```
181Filter {
182 ids []bytes // match by event id
183 authors []bytes // match by pubkey
184 kinds []uint16 // match by event kind
185 since int64
186 until int64
187 limit int32
188 tags []TagFilter
189}
190
191TagFilter {
192 name string
193 values []string // match any
194}
195```
196
197---
198
199## Relay Internals
200
201The relay unmarshals only what it needs for indexing and routing. `content` is never parsed — it is opaque bytes as far as the relay is concerned.
202
203**On ingest:**
2041. Unmarshal the event envelope to extract index fields (`id`, `pubkey`, `kind`, `created_at`, `tags`)
2052. Verify signature: recompute `id`, check `ed25519.Verify(pubkey, id, sig)`
2063. Reject if `id` already exists — `id PRIMARY KEY` makes duplicate events impossible to store, and the fanout path checks an in-memory seen set before forwarding
2074. Write index fields to the index tables
2085. Write the verbatim msgpack envelope bytes to `envelope_bytes` — the entire event exactly as received, not re-serialized
2096. Fanout to matching subscribers
210
211**On query/fanout:**
212- Read `envelope_bytes` from store
213- Forward directly to subscribers — no unmarshal, no remarshal
214
215**Index schema (SQLite or Postgres):**
216
217```sql
218CREATE TABLE events (
219 id BLOB PRIMARY KEY,
220 pubkey BLOB NOT NULL,
221 created_at INTEGER NOT NULL,
222 kind INTEGER NOT NULL,
223 envelope_bytes BLOB NOT NULL -- verbatim msgpack bytes of the full event, including content
224);
225
226CREATE TABLE tags (
227 event_id BLOB REFERENCES events(id),
228 name TEXT NOT NULL,
229 value TEXT NOT NULL
230);
231
232CREATE INDEX ON events(pubkey);
233CREATE INDEX ON events(kind);
234CREATE INDEX ON events(created_at);
235CREATE INDEX ON tags(name, value);
236```
237
238---
239
240## Event Kinds
241
242Integer kinds with named constants. The integer is the wire format; the name is what appears in code and logs. Ranges enable efficient category queries without enumerating individual kinds.
243
244### Range Allocation
245
246| Range | Category |
247|---|---|
248| 0000 – 0999 | Identity & meta |
249| 1000 – 1999 | Messaging |
250| 2000 – 2999 | Encrypted messaging |
251| 3000 – 3999 | Presence & ephemeral |
252| 4000 – 4999 | Reserved |
253| 5000 – 5999 | Job requests |
254| 6000 – 6999 | Job results |
255| 7000 – 7999 | Job feedback |
256| 8000 – 8999 | System / relay |
257| 9000 – 9999 | Reserved |
258
259### Defined Kinds
260
261| Constant | Kind | Description |
262|---|---|---|
263| `KindProfile` | 0 | Identity metadata |
264| `KindMessage` | 1000 | Plain text note |
265| `KindDM` | 2000 | Encrypted direct message |
266| `KindProgress` | 3000 | Ephemeral progress/status indicator (thinking, agent steps, job status) |
267| `KindJobRequest` | 5000 | Request for agent work |
268| `KindJobFeedback` | 7000 | In-progress status / error |
269| `KindJobResult` | 6000 | Completed job output |
270
271### Range Queries
272
273```sql
274-- all job-related events
275WHERE kind >= 5000 AND kind < 8000
276
277-- ephemeral events (relay does not persist)
278WHERE kind >= 3000 AND kind < 4000
279```
280
281Ephemeral events (kind 3000–3999) are fanned out to subscribers but never written to the store.
282
283---
284
285## Threading
286
287Conversations use explicit `e` tags with mandatory role markers:
288
289```
290Tag{ name: "e", values: ["<event-id>", "root"] }
291Tag{ name: "e", values: ["<event-id>", "reply"] }
292```
293
294Root marker is required on all replies. No fallback heuristics.
295
296---
297
298## Direct Messages
299
300`KindDM` (2000) events carry ChaCha20-Poly1305 encrypted content. The recipient is identified by a `p` tag carrying their pubkey:
301
302```
303Tag{ name: "p", values: ["<recipient-pubkey>"] }
304```
305
306The relay indexes the `p` tag to route DMs to the recipient's subscription. Content is opaque; the relay cannot decrypt it.
307
308---
309
310## Job Protocol
311
312Any client can publish a `KindJobRequest`; any agent subscribed to the relay can fulfill it. The flow:
313
314```
315KindJobRequest (5000) → { kind: 5000, content: "<prompt>", tags: [["t", "<job-type>"]] }
316KindJobFeedback (7000) → { kind: 7000, content: "<status>", tags: [["e", "<request-id>"]] }
317KindJobResult (6000) → { kind: 6000, content: "<output>", tags: [["e", "<request-id>"]] }
318```
319
320Multiple agents can compete to fulfill the same request. The requester can target a specific agent with a `p` tag.
321
322**Expiry:** job requests may include an `expires_at` tag carrying a unix timestamp. Agents must check this before starting work and skip expired requests. The relay does not enforce expiry — it is agent-side policy.
323
324```
325Tag{ name: "expires_at", values: ["<unix timestamp>"] }
326```
327
328---
329
330## Consumers
331
332The relay is the log. Anything requiring derived data subscribes and maintains its own view:
333
334- **Search indexer** — subscribes to all events, feeds full-text index
335- **Daily report** — subscribes to past 24h, generates summary via agent
336- **Metrics collector** — counts event types, feeds dashboard
337- **Conversation summarizer** — subscribes to completed threads
338
339Each consumer is independent and can rebuild from relay replay on restart.
340
341**Resumption:** consumers track their own position by storing the `created_at` of the last processed event and resuming with a `since` filter on restart. Use event `id` to deduplicate any overlap at the boundary.
342
343---
344
345## Threat Model
346
347**DM metadata:** `KindDM` content is encrypted and opaque to the relay, but sender pubkey and recipient `p` tag are stored in plaintext. The relay operator can see who is talking to whom and when. Content is private; the social graph is not.
348
349---
350
351## What This Is Not
352
353- Not a database. Don't query it like one.
354- Not a general message queue. It has no consumer groups or offset tracking — consumers manage their own position.
355- Not decentralized. Single relay, single operator. Multi-relay federation is out of scope.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6f6a363
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
1# Axon
2
3A signed event relay protocol for AI agent infrastructure.
4
5Axon is the transport and identity layer for systems where agents, humans, and automated jobs need to communicate over a shared bus. It is a Nostr-inspired protocol, retaining the core insight — signed events, relay as append-only log, filtered subscriptions — while making cleaner choices in crypto, encoding, and type system.
6
7## Design Principles
8
9- **The relay is a log, not a database.** It routes and stores signed events. Derived data lives downstream in consumers.
10- **Identity is a keypair.** Ed25519 public keys are the unit of identity. No passwords, no tokens, no certificate infrastructure.
11- **Content is opaque.** The relay indexes what it needs for routing and stores the rest as raw bytes. It cannot read what it was not designed to index.
12- **Kafka at the edge, plus identity.** Filtered subscriptions over WebSocket give browsers and agents direct access to the event stream without a gateway layer.
13
14## Protocol
15
16See [PROTOCOL.md](PROTOCOL.md) for the full specification, including:
17
18- Event structure and canonical signing payload
19- Crypto stack (Ed25519, X25519, ChaCha20-Poly1305)
20- Wire format (MessagePack over WebSocket)
21- Connection authentication
22- Event kind registry and range allocation
23- Job protocol for agentic workloads
24- Relay internals and index schema
25- Threat model
26
27## Status
28
29Protocol design. No implementation yet.