diff options
| author | bndw <ben@bdw.to> | 2026-03-08 21:54:30 -0700 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-03-08 21:54:30 -0700 |
| commit | e3ac28b9f829c54f88db20245ad58b7d64629d19 (patch) | |
| tree | 4180f05286a7545b5e2bc9d5379b0ebb2a6ec1ff | |
initial: Axon protocol spec and README
| -rw-r--r-- | PROTOCOL.md | 355 | ||||
| -rw-r--r-- | README.md | 29 |
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 | |||
| 9 | The 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 | |||
| 22 | Consumers (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 | ``` | ||
| 29 | Event { | ||
| 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 | |||
| 39 | Tag { | ||
| 40 | name string | ||
| 41 | values []string | ||
| 42 | } | ||
| 43 | ``` | ||
| 44 | |||
| 45 | ### Signing | ||
| 46 | |||
| 47 | The event ID is the `SHA256` of a canonical byte payload. All integers are big-endian. All strings are UTF-8. `||` denotes concatenation. | ||
| 48 | |||
| 49 | ``` | ||
| 50 | id = SHA256(canonical_payload) | ||
| 51 | sig = 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 | |||
| 66 | Tags 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 | ``` | ||
| 69 | uint16(num_tags) | ||
| 70 | for 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 | |||
| 77 | The `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 | |||
| 93 | Two 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 | |||
| 106 | All 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 | |||
| 115 | MessagePack 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 | |||
| 119 | Authentication happens immediately on connect before any other messages are accepted. | ||
| 120 | |||
| 121 | ``` | ||
| 122 | relay → Challenge { nonce: bytes } // 32 random bytes | ||
| 123 | client → Auth { pubkey: bytes, sig: bytes } | ||
| 124 | relay → Ok { message: string } // or Error then close | ||
| 125 | ``` | ||
| 126 | |||
| 127 | The client signs over `nonce || relay_url` to prevent replay to a different relay: | ||
| 128 | |||
| 129 | ``` | ||
| 130 | sig = ed25519.Sign(privkey, SHA256(nonce || utf8(relay_url))) | ||
| 131 | ``` | ||
| 132 | |||
| 133 | The 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 | ``` | ||
| 140 | Auth { pubkey: bytes, sig: bytes } | ||
| 141 | Subscribe { sub_id: string, filter: Filter } | ||
| 142 | Unsubscribe { sub_id: string } | ||
| 143 | Publish { event: Event } | ||
| 144 | ``` | ||
| 145 | |||
| 146 | ### Relay → Client | ||
| 147 | |||
| 148 | ``` | ||
| 149 | Challenge { nonce: bytes } | ||
| 150 | EventEnvelope { sub_id: string, event: Event } | ||
| 151 | Eose { sub_id: string } | ||
| 152 | Ok { message: string } | ||
| 153 | Error { code: uint16, message: string } | ||
| 154 | ``` | ||
| 155 | |||
| 156 | Each message is a msgpack array: `[message_type, payload]` where `message_type` is a uint16. | ||
| 157 | |||
| 158 | ### Error Codes | ||
| 159 | |||
| 160 | HTTP 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 | |||
| 170 | The 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 | |||
| 174 | The 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 | ``` | ||
| 181 | Filter { | ||
| 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 | |||
| 191 | TagFilter { | ||
| 192 | name string | ||
| 193 | values []string // match any | ||
| 194 | } | ||
| 195 | ``` | ||
| 196 | |||
| 197 | --- | ||
| 198 | |||
| 199 | ## Relay Internals | ||
| 200 | |||
| 201 | The 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:** | ||
| 204 | 1. Unmarshal the event envelope to extract index fields (`id`, `pubkey`, `kind`, `created_at`, `tags`) | ||
| 205 | 2. Verify signature: recompute `id`, check `ed25519.Verify(pubkey, id, sig)` | ||
| 206 | 3. 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 | ||
| 207 | 4. Write index fields to the index tables | ||
| 208 | 5. Write the verbatim msgpack envelope bytes to `envelope_bytes` — the entire event exactly as received, not re-serialized | ||
| 209 | 6. 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 | ||
| 218 | CREATE 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 | |||
| 226 | CREATE TABLE tags ( | ||
| 227 | event_id BLOB REFERENCES events(id), | ||
| 228 | name TEXT NOT NULL, | ||
| 229 | value TEXT NOT NULL | ||
| 230 | ); | ||
| 231 | |||
| 232 | CREATE INDEX ON events(pubkey); | ||
| 233 | CREATE INDEX ON events(kind); | ||
| 234 | CREATE INDEX ON events(created_at); | ||
| 235 | CREATE INDEX ON tags(name, value); | ||
| 236 | ``` | ||
| 237 | |||
| 238 | --- | ||
| 239 | |||
| 240 | ## Event Kinds | ||
| 241 | |||
| 242 | Integer 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 | ||
| 275 | WHERE kind >= 5000 AND kind < 8000 | ||
| 276 | |||
| 277 | -- ephemeral events (relay does not persist) | ||
| 278 | WHERE kind >= 3000 AND kind < 4000 | ||
| 279 | ``` | ||
| 280 | |||
| 281 | Ephemeral events (kind 3000–3999) are fanned out to subscribers but never written to the store. | ||
| 282 | |||
| 283 | --- | ||
| 284 | |||
| 285 | ## Threading | ||
| 286 | |||
| 287 | Conversations use explicit `e` tags with mandatory role markers: | ||
| 288 | |||
| 289 | ``` | ||
| 290 | Tag{ name: "e", values: ["<event-id>", "root"] } | ||
| 291 | Tag{ name: "e", values: ["<event-id>", "reply"] } | ||
| 292 | ``` | ||
| 293 | |||
| 294 | Root 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 | ``` | ||
| 303 | Tag{ name: "p", values: ["<recipient-pubkey>"] } | ||
| 304 | ``` | ||
| 305 | |||
| 306 | The 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 | |||
| 312 | Any client can publish a `KindJobRequest`; any agent subscribed to the relay can fulfill it. The flow: | ||
| 313 | |||
| 314 | ``` | ||
| 315 | KindJobRequest (5000) → { kind: 5000, content: "<prompt>", tags: [["t", "<job-type>"]] } | ||
| 316 | KindJobFeedback (7000) → { kind: 7000, content: "<status>", tags: [["e", "<request-id>"]] } | ||
| 317 | KindJobResult (6000) → { kind: 6000, content: "<output>", tags: [["e", "<request-id>"]] } | ||
| 318 | ``` | ||
| 319 | |||
| 320 | Multiple 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 | ``` | ||
| 325 | Tag{ name: "expires_at", values: ["<unix timestamp>"] } | ||
| 326 | ``` | ||
| 327 | |||
| 328 | --- | ||
| 329 | |||
| 330 | ## Consumers | ||
| 331 | |||
| 332 | The 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 | |||
| 339 | Each 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 | |||
| 3 | A signed event relay protocol for AI agent infrastructure. | ||
| 4 | |||
| 5 | Axon 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 | |||
| 16 | See [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 | |||
| 29 | Protocol design. No implementation yet. | ||
