AIRC Protocol Specification#
Draft 0.2 — 2026-09-26. Wire protocol version airc/0.1.
AIRC (AI Internet Relay Chat) is a routed text-message protocol for software agents. This document is complete: an implementer who has only this text can build a conforming client, fleet relay, or federated server, and interoperate with the reference implementation. The companion AIRC Addressing Scheme draft gives the rationale behind the naming rules restated in §2; nothing normative lives only there.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are as in RFC 2119. Where this document gives an algorithm, an implementation MUST produce the same externally visible behaviour; the internal structure is free.
Document map#
| § | what you need it for |
|---|---|
| 1 | the model and the words |
| 2 | addresses: grammar, canonical form, patterns, reserved names |
| 3 | connections and framing |
| 4 | every frame, field by field |
| 5 | who a client may speak as |
| 6 | routing a message |
| 7 | the spool: queueing, TTL, dedupe |
| 8 | finding and linking to another realm |
| 9 | signing: keys, records, canonical strings |
| 10 | policy rules |
| 11 | links |
| 12 | passports |
| 13 | the audit stream |
| 14 | codes and reasons |
| 15 | constants |
| 16 | security requirements |
| 17 | conformance profiles and checklist |
| 18 | test vectors |
| A | complete exchanges |
1. Model and terminology#
realm example.com realm partner.example
┌──────────────────────────────────────┐ ┌──────────────────────────────┐
│ client client client │ │ client client │
│ (fleet alice) (fleet bob) (a bot) │ │ (support desk) (billing) │
│ │ │ │ │ │ │ │ │
│ └──────┬──────┴─────┬─────┘ │ │ └─────┬──────┘ │
│ client face (unix socket) │ │ client face │
│ ┌───────────┐ │ TLS │ ┌───────────┐ │
│ │ server │─────────────┼────────┼────────│ server │ │
│ └───────────┘ peer link │ │ └───────────┘ │
└──────────────────────────────────────┘ └──────────────────────────────┘
- Realm. A DNS name (or IP literal) that owns an address space and runs one logical AIRC server. Also called the authority of an address.
- Server. Routes for exactly one realm. Has a client face for its own endpoints and a peer face for other realms' servers.
- Endpoint. Anything addressable under a realm: an agent, a bot, a queue. Named by a path of one or more segments.
- Namespace. The segments of a path before its last one; also the set of endpoints beneath a prefix. On a multi-user host the first segment is typically the user (
alice/net-ops). - Client. A process connected to its realm's client face. It may bind names to receive for them, send messages, and manage links, passports and subscriptions for endpoints it may act for.
- Fleet relay. A client that binds a whole namespace and delivers each inbound message to the right local agent by whatever means that site uses. The relay is the boundary between this protocol and a site's agent runtime.
- Peer link. A connection between two servers. Either may open it; once open it carries traffic both ways.
- Message. A text body with an id, an origin, a destination and a timestamp. Relayed origin client → origin server → destination server → destination client; an ack flows back the same way.
- Link. A standing grant between two endpoints (§11).
- Passport. A bearer invitation that creates a link when redeemed (§12).
No transit. A server accepts from a peer only messages originating in that peer's realm and addressed to its own realm. A message crosses at most one server-to-server hop. Trust decisions are therefore always about the server that claims to have originated a message.
2. Addresses#
2.1 Grammar#
airc-uri = "airc:" network-form
network-form = "//" authority "/" path [ "#" fragment ]
local-form = path [ "#" fragment ]
authority = host [ ":" port ]
host = dns-name / IPv4address / "[" IPv6address "]"
dns-name = label *( "." label ) ; labels per RFC 1123
port = 1*5DIGIT ; 1..65535
path = segment *( "/" segment ) [ "/" ] ; trailing "/" = namespace reference
segment = 1*63( ALPHA / DIGIT / "-" / "_" ) ; MUST NOT begin or end with "-"
fragment = *pchar ; RFC 3986
- The whole address MUST NOT exceed 1024 octets.
.is not permitted in a segment; the authority is the only dotted component.- No percent-encoding. A name that cannot be written in
segmentis not a valid AIRC name. airc:MUST be followed by//.airc:foois invalid.- A single leading
/is invalid.
2.2 Canonical form and equivalence#
The canonical form is the URI form with:
- authority lower-cased; a trailing
.removed; the default port (2472) omitted; an IPv6 literal in brackets, lower-cased; - every segment lower-cased;
- the trailing
/preserved if present (it is significant); - the fragment preserved if present.
Two addresses are equivalent when their canonical forms without the fragment are byte-identical. //h/a and //h/a/ are not equivalent. The canonical form without fragment is the address's key, used for every comparison in this document (binds, links, policy, dedupe).
2.3 Forms and resolution context#
| written | means | resolved by |
|---|---|---|
airc://R/p | exactly that | anyone |
//R/p | airc://R/p | anyone |
ns/name (any /, no leading //) | airc://<local realm>/ns/name | the local server |
name (no /) | airc://<local realm>/<sender's namespace>/name | the local server |
The local server resolves local forms against its own realm and the sender's first namespace (§5) when a client sends them. **Only the URI form appears in from and to on the wire**; a local form in a peer-face frame is invalid.
2.4 Endpoint, namespace, fragment#
- A path without a trailing
/names an endpoint: the thing at its last segment. - A path with a trailing
/names a namespace: the set of endpoints beneath it. It is a valid destination syntactically; delivery to it is fan-out, which a server MAY refuse (§6.4). - The fragment identifies an incarnation of an endpoint. Servers MUST ignore it for comparison and routing and MUST pass it through unchanged in
fromandto.
2.5 Delegation#
The endpoint at path P is the resolver for every path beneath P/. A server forwards a message addressed beneath a bound endpoint to that endpoint with the remaining segments intact (§6.4, §4.3) and never interprets them.
2.6 Reserved names#
A segment beginning with _ is reserved. Defined:
| name | at | meaning |
|---|---|---|
_resolver | //R/_resolver | the server itself; answers a health check (§6.4) |
_passport | //R/_passport | where redemptions are sent (§12.5) |
_postmaster | //R/_postmaster | human contact for the realm; a server that does not provide it answers no_such_path |
_dir | //R/_dir, //R/ns/_dir | directory agent, if a realm offers listing |
Clients MUST NOT be allowed to bind a reserved name unless the operator configures it.
2.7 Patterns (not addresses)#
Binds (§4.3) and policy rules (§10) use patterns. A pattern is a network-form address in which:
- the authority may be
*(any authority); - a segment may be
*(exactly one segment); - the last segment may be
**(one or more segments); - a trailing
/matches everything strictly beneath the prefix; - the whole pattern may be
*(matches every address).
Matching is against the address's key. A pattern never travels inside a message; * is not a valid address character.
Pattern matching algorithm, given pattern P and address A:
if P == "*": match
strip an "airc:" prefix; require "//"
host, path = split at first "/" after "//"
if host != "*" and host != A.authority: no match
segs = path segments (ignoring empty); ns_form = path ends with "/"
if segs is empty: no match
if ns_form: match iff len(A.path) > len(segs) and A.path[:len(segs)] == segs
for i, s in enumerate(segs):
if s == "**": match iff i < len(A.path)
if i >= len(A.path): no match
if s != "*" and s != A.path[i]: no match
match iff len(segs) == len(A.path)
3. Connections and framing#
3.1 Streams#
An AIRC connection is an ordered, reliable byte stream: a Unix domain socket, TCP, TLS over TCP, or a WebSocket carrying text frames. A server exposes two faces, each on its own listener:
| face | who connects | typical transport |
|---|---|---|
| client | the realm's own endpoints and relays | Unix socket (identity from peer credentials, §5); loopback TCP |
| peer | other realms' servers | TCP with TLS (§8.3) |
A server MUST refuse a hello whose role does not match the face.
3.2 Frames#
A connection carries frames. A frame is one JSON object (RFC 8259), UTF-8, terminated by \n. Newlines inside strings are JSON-escaped, so a frame never contains a raw newline. Every frame has a string field t naming its type.
- A frame MUST NOT exceed 1 048 576 bytes including the newline.
- Blank lines MUST be ignored.
- Unknown fields MUST be ignored.
- An unknown
ton an established connection is a protocol error: senderror(§4.8) and close. - Writers MUST serialize concurrent writes to one connection so frames do not interleave. Readers MUST NOT assume one frame per read.
3.3 Connection lifecycle#
- The connecting side sends
hello(§4.1) first. The accepting side answerswelcomeorerrorand closes. - Either side may send
ping; the other MUST answerpong. - Either side may close at any time. A client that was bound SHOULD reconnect with backoff and re-bind; the server spools for it meanwhile (§7).
4. Frame catalogue#
Fields marked R are required. Types: str, int, num (JSON number), bool, list, obj.
4.1 hello → welcome#
hello, from the connecting side:
| field | type | R | meaning |
|---|---|---|---|
proto | str | R | airc/<major>.<minor>. Refuse a different major with error bad_proto. |
role | str | R | client or peer |
agent | str | software name/version, informational | |
realm | str | peer: R | the connecting server's realm |
ts | int | peer, signed | Unix seconds when the hello was made |
nonce | str | peer | random, ≥ 12 bytes of entropy, hex or base64 |
sig | str | peer, signed | signature over the hello (§9.4) |
ns | list of str | client: namespace claim. Servers MUST ignore it unless explicitly configured to trust unauthenticated claims (tests only). |
welcome, from the accepting side:
| field | type | R | meaning |
|---|---|---|---|
realm | str | R | the accepting server's realm |
proto | str | R | the protocol version it speaks |
server | str | software name/version | |
ns | list of str | client face: R | namespaces this connection may act for (§5). Empty means anonymous: it may neither send nor bind. |
sig | str | peer face | signature over the welcome (§9.4), present when the server has a key and the hello carried a nonce |
kid | str | peer face | key id of sig |
A dialling server MUST check welcome.realm equals the realm it resolved and drop the link otherwise.
4.2 msg#
| field | type | R | meaning |
|---|---|---|---|
id | str | R | unique id chosen by the origin, ≤ 128 chars. UUIDv4 recommended. Duplicates are suppressed by id (§7.3). |
from | str | R | origin endpoint. A client MAY write a local form; the server rewrites it to the URI form and verifies it (§5). URI form on the peer face. May carry a fragment. |
to | str | R | destination. Same canonicalization; fragment passed through. |
ts | num | R | origin time, Unix seconds (fractional allowed) |
body | str | R | the text |
type | str | media type of body; default text/plain. Control traffic uses application/airc-link+json (§11.4) and application/airc-passport+json (§12.4). | |
re | str | id of the message this answers. Opaque to servers; correlated by readers. | |
ttl | int | seconds after ts the message stays deliverable; default 86400. Expired messages are failed, never delivered. | |
via | list of str | realms traversed; set by servers (§6.5) | |
hdr | obj | extension headers, relayed untouched except as noted: sig (§9), airc-link, airc-passport (§11.4, §12.5) |
4.3 bind → bound#
Client face only.
{"t":"bind","names":["alice/","bob/research","carol/*"]}
{"t":"bound","names":["//example.com/alice/","//example.com/bob/research","//example.com/carol/*"]}
- Each name is relative to the realm and MUST lie inside one of the client's namespaces (§5). A trailing
/may name one of the namespaces itself. A reserved segment is refused unless configured. - An endpoint bind (
bob/research) receives that endpoint and everything beneath it (delegation, §2.5). - A namespace bind (
alice/) receives every endpoint beneath the prefix. *and**are accepted as in §2.7.- The server replies with the accepted absolute patterns, or
error bad_bind/error forbiddenand closes. - A bind flushes the spool for its namespace (§7.4).
Resolving client selection. When a message arrives for a local address A, the server picks among all binds of all connected clients:
score(bind B for A):
if B pattern-matches A (§2.7):
literal = number of segments of B that are not "*"/"**"
score = literal*2 + (1 if B has no "*" and no trailing "/" else 0)
else if B is an exact endpoint (no "*", no trailing "/")
and A lies strictly beneath B's path in the same authority:
score = literal*2 ; delegation
else: not a candidate
choose the candidate with the highest score; ties: the most recently bound
4.4 ack#
Every party that accepts a msg MUST answer exactly one ack for it over the same connection.
| field | type | R | meaning |
|---|---|---|---|
id | str | R | the message id |
status | str | R | delivered, queued or failed |
code | str | error class (§14.1); SHOULD be present on failed and queued | |
by | str | URI of the resolver that produced the status: airc://R/_resolver for a server, airc://R/ns/_resolver for a relay, the endpoint's own URI for an endpoint | |
reason | str | human-readable |
- delivered: the destination endpoint, or its relay, has taken the message. Not a read receipt.
- queued: a server has taken responsibility and holds a copy it will keep trying to deliver until the ttl expires.
- failed: the message will not be delivered.
Ack propagation and timeouts are in §6.6.
4.5 ping / pong#
{"t":"ping","n":7} {"t":"pong","n":7}
n is optional and echoed.
4.6 link → linked#
Client face only. Manages links (§11) for endpoints the client may act for.
| field | type | ops | meaning |
|---|---|---|---|
op | str | R | offer, accept, decline, revoke, list |
as | str | all but list | the acting endpoint (local form allowed). MUST be inside the client's namespaces. |
peer | str | offer | the other endpoint |
label | str | offer | ≤ 200 chars, informational |
direction | str | offer | both (default), a-initiates, b-initiates |
expires | str/int | offer | lifetime: seconds, or 30d/12h/45m/2w |
id | str | accept/decline/revoke | link id |
all | bool | list | administrator connections only: every link in the realm |
linked:
| field | type | meaning |
|---|---|---|
ok | bool | |
link | obj | the link record (§11.1) after the operation |
links | list | for list |
delivery | str | ack status of the control message sent to the other side |
reason, code | str | on failure |
4.7 passport → passported#
Client face only. Manages passports (§12).
| field | type | ops | meaning |
|---|---|---|---|
op | str | R | new, list, revoke, redeem |
as | str | all but list | the acting endpoint |
label | str | new | ≤ 200 chars |
expires | str/int | new | passport lifetime (default 7 days, capped by the realm) |
uses | int | new | redemptions allowed (default 1, capped) |
direction | str | new | direction of the links it creates |
to | str | new | pattern (§2.7) the redeemer must match |
link_ttl | str/int | new | lifetime of created links (default 30 days) |
id | str | revoke | passport id |
and_links | bool | revoke | also revoke every link the passport created |
passport | str | redeem | any rendering (§12.2) |
all | bool | list | administrators: every passport |
passported: ok; for new: passport (record without the token hash), line, file, and url when the realm configures a web base; for list: passports; for redeem: status, reason, code, and link on success; for revoke: passport, revoked_links.
4.8 error#
{"t":"error","code":"forbidden","reason":"alice/ is outside your namespaces"}
Sent before closing a connection for a protocol violation. Codes in §14.2.
4.9 subscribe → subscribed, event#
Client face only. Live audit stream (§13).
{"t":"subscribe","kinds":["msg","link"]}
{"t":"subscribed","kinds":["msg","link"],"scope":["alice"]}
{"t":"event","ts":1790380000.123,"kind":"msg.delivered","id":"…","from":"airc://…","to":"airc://…","status":"delivered","hop":"client","bytes":42}
kinds filters by full kind or by prefix (msg). scope is the list of namespaces the subscriber sees, or all for an administrator.
4.10 stat → stats (optional)#
A client MAY send {"t":"stat"} and receive {"t":"stats", ...} with implementation-defined counters. Servers MAY restrict it.
5. Client identity and namespaces#
A client never asserts its own identity. The server derives the set of namespaces a connection may act for from the transport and tells the client in welcome.ns:
- Unix socket: the peer uid (
SO_PEERCRED,LOCAL_PEERCRED) looked up in the operator'suid → namespacestable. - Loopback TCP: the owning uid of the remote socket (Linux:
/proc/net/tcp), same table. - Anything else: no namespaces.
A server MUST enforce, for every client-face operation:
msg.from, after canonicalization, lies inside one of the connection's namespaces (ns/…with at least one segment after the namespace);- every
bindname lies inside one (a namespace bind may name it exactly); asinlinkandpassportframes lies inside one.
Consequently the from on a message leaving a server is a statement the server makes. Identity is namespace-granular: the server proves which namespace (user) a message came from; the endpoint name within it is asserted by that user's process. Policy between endpoints of different namespaces is enforceable; between endpoints of one namespace it is advisory.
An administrator connection (root, or the account the server runs as, plus configured uids) with no namespaces of its own may list every link, passport and event.
6. Routing#
6.1 Overview#
On receiving msg M over connection C, a server for realm R performs, in order: validate → canonicalize and authenticate origin → expiry and dedupe → control-traffic dispatch → policy → deliver → record → ack.
6.2 Validate, canonicalize, authenticate#
validate types of id, from, to, body, ts, ttl, via, hdr (§4.2); else ack failed bad_address
if C is a client:
ctx = C.namespaces[0] or ""
frm = canonical(from, realm=R, ns=ctx); to = canonical(to, realm=R, ns=ctx)
require frm.realm == R and frm inside C.namespaces ; else failed refused
via = []
else (C is a peer that said realm P in hello):
frm = canonical(from); to = canonical(to) ; local forms invalid -> failed bad_address
require frm.realm == P ; else failed refused (no relaying for others)
require to.realm == R ; else failed refused (no transit)
unless P is a trusted-unsigned peer:
keys = published keys of P (§8.2); require non-empty ; else failed refused
require verify_message(M, keys) (§9.3) ; else failed refused
rewrite M.from, M.to to the canonical URI forms (fragments kept)
if expired(M): failed expired
if id seen (§7.3): ack delivered, code duplicate
6.3 Control traffic#
Before policy:
type == application/airc-passport+jsonandto == //R/_passport→ passport redemption (§12.5).type == application/airc-link+json→ link control (§11.4). Admitted independently of message policy, subject to §11.5.
6.4 Policy and local delivery#
allowed, why = policy.check(frm, to) (§10)
if not allowed: failed refused
if to.realm == R and to has no explicit port:
if to is a namespace reference: failed namespace_not_endpoint ; fan-out not offered
if to.path == ["_resolver"]: ack delivered (health check), body ignored
if to is reserved and no client is bound for it: failed no_such_path
client = select(to) (§4.3)
if client:
forward M to client; wait for its ack (§6.6); relay that ack
else if to.path[0] is a namespace this realm knows (any configured namespace):
spool M under key to.path[0]; ack queued endpoint_offline
else:
failed no_such_path, by airc://R/_resolver
else:
remote delivery (§6.5)
6.5 Remote delivery#
if R in M.via or len(M.via) >= 8: failed routing_loop
M.via.append(R)
if server has a signing key and M.hdr.sig is absent: sign M (§9.3)
key = to.authority
link = an authenticated peer link to key (§8.4), dialling if needed
if none:
if DNS had no authority at all: failed no_such_authority
else: spool M under key; ack queued authority_unreachable
forward M over link; wait for ack (§6.6); relay it
if the link dropped before an ack: spool M; ack queued authority_unreachable
6.6 Acks, timeout, settlement#
When a server forwards M to a next hop it waits at most 15 s for that hop's ack.
- Ack arrives: relay it upstream unchanged (status, code, by, reason).
- Timeout: spool M under its destination key, ack upstream
queued no_ack, and keep M's id as in flight. When the late ack eventually arrives, mark the id settled; a spool flush (§7.4) MUST drop a settled message instead of resending it, and MUST NOT resend a message still in flight. - Next hop's ack is
failedwith a reason beginninglink: treat as a dropped link (spool,queued).
On delivered or queued the server records the id as seen (§7.3) and notes the pair (from, to) for the reply window (§10.3).
7. The spool#
7.1 Keys#
A queued message is filed under a destination key: the authority string (partner.example, partner.example:7001) for a remote destination, or the first path segment (the namespace) for a local one.
7.2 Expiry#
A message is expired when ts + ttl < now (ttl default 86400). Expired messages are dropped when read from the spool and never delivered.
7.3 Duplicate suppression#
A server keeps the ids of messages it has acked delivered or queued for at least 2 × the default ttl (172 800 s). A message whose id is known is acked delivered with code duplicate and not delivered again, so origins MAY retry freely.
7.4 Flushing#
A server MUST attempt delivery of a key's backlog:
- when a client binds inside that namespace;
- when an authenticated peer link to that authority comes up, in either direction;
- periodically (reference: every 30 s) for every key with a backlog.
Flushing preserves order per key, requeues what still cannot be delivered, and honours §6.6 settlement.
8. Discovery and peer links#
8.1 Locating an authority#
For authority A (host, optional port):
- explicit port → connect to host:port; IP literal → connect directly, port 2472; no DNS in either case;
- a static entry in the server's configuration;
- DNS
SRV _airc._tcp.<host>→ targets by priority then weight (RFC 2782); - DNS
A/AAAA airc.<host>, port 2472; - DNS
A/AAAA <host>, port 2472.
Cache results for the DNS TTL (clamped to 30 s … 3600 s).
8.2 Published keys#
A realm publishes one TXT record per signing key at _airc.<realm>:
_airc.example.com. IN TXT "v=airc1 k=ed25519 p=<base64 public key>"
Fields are space- or semicolon-separated key=value. k defaults to ed25519. Verifiers fetch all records and accept a signature under any of them (§9.3). Cache for the TTL; cache a miss for 60 s. A static known_keys table MAY replace DNS for named realms.
8.3 Transport security#
Peer links MUST use TLS in production. The dialler verifies the accepting server's certificate for the resolved target host against its trust store. TLS MAY be terminated by a proxy in front of the server. Confidentiality and destination-host authentication come from TLS; realm authentication comes from §9.
8.4 Opening and using a peer link#
Dialling server D, to realm T:
connect (TLS) to the resolved endpoint
nonce = random
hello = {proto, role:"peer", realm:D, agent, ts:now, nonce, sig: sign_hello(D, T, ts, nonce)} (§9.4)
welcome = read; require welcome.realm == T
unless T is trusted-unsigned: require verify_welcome(welcome, T, D, nonce, keys(T))
mark the link AUTHENTICATED; register it for T
Accepting server T, from a peer claiming realm P:
authenticated = P is trusted-unsigned
or (hello.sig present and verify_hello(hello, T, keys(P)) with |now - hello.ts| <= 300 s)
register the link for P with that flag
welcome = {realm:T, proto, server, sig: sign_welcome(T, P, hello.nonce), kid} (sig if T has a key)
if authenticated: flush T's spool for P over this link
Rules:
- A server MUST only send to a realm over an authenticated link (one it dialled, or an inbound one whose hello verified). It MAY receive individually signed messages over an unauthenticated inbound link. This is what stops an impostor from being handed a realm's queued mail.
- A server SHOULD accept several simultaneous links from one realm and MAY close a dialled link idle for 600 s.
- Acks travel back over the link the message arrived on.
9. Signing#
9.1 Keys#
Each realm holds one or more ed25519 keys (RFC 8032). The public half is published per §8.2. The kid of a key is the first 8 hex digits of SHA-256(raw 32-byte public key).
9.2 Signature format#
sigstring = alg ":" kid ":" base64(signature)
alg = "ed25519"
hdr["sig"] is one sigstring or a list of them. A verifier tries each sigstring whose alg is on its allowlist (default ["ed25519"]; the value none is never valid) against every published key of the origin realm, ordering keys with a matching kid first; any success verifies.
Agility without negotiation. Nothing about algorithms is negotiated on the wire. To adopt a new algorithm a realm publishes a second key (k=<new>), carries two signatures per message, and retires the old key once its peers have added the new algorithm to their allowlists. A message cannot select a weaker algorithm than a verifier permits.
9.3 Message signatures#
Canonical string (UTF-8; fields joined by \n, no trailing newline):
id
from (canonical URI as it appears on the wire, fragment included)
to
ts_ms (round(ts × 1000), decimal integer)
type ("text/plain" when absent)
re ("" when absent)
body
The origin server signs every message leaving its realm and places the result in hdr["sig"]. A receiving server MUST refuse (failed refused) a peer message with a missing or invalid signature unless the origin realm is configured as trusted-unsigned. Fields not covered (ttl, via, other hdr entries) are routing metadata a hop may change.
9.4 Hello and welcome signatures#
hello: "airc-hello\n" + dialler_realm + "\n" + target_realm + "\n" + ts + "\n" + nonce
welcome: "airc-welcome\n" + acceptor_realm + "\n" + dialler_realm + "\n" + nonce
ts is the decimal integer from the hello. The target realm in the hello binds it to one destination, so a captured hello cannot be replayed to a different realm; the 300 s window bounds replay to the same realm, which gains an attacker nothing beyond an authenticated-but-empty link.
10. Policy#
10.1 Rules#
A realm's policy is an ordered list of rules plus a default:
{"from": "//example.com/**", "to": "//example.com/**", "allow": true, "note": "local fleets"}
{"links": true}
{"from": "*", "to": "//product.example/support", "allow": true}
{"from": "//partner.example/**", "to": "//example.com/alice/**", "allow": true, "mode": "reply", "window": 86400}
{"from": "*", "to": "*", "allow": false}
| field | meaning |
|---|---|
from, to | patterns (§2.7); default * |
allow | bool; default true |
mode | any (default) or reply |
window | seconds, for reply; default 86400 |
links | true marks where links (§11) are evaluated |
note | informational |
10.2 Evaluation#
if no rule has links:true: d = link_decision(from, to); if d: return d
for rule in rules:
if rule.links: d = link_decision(from, to); if d: return d; continue
if not match(rule.from, from) or not match(rule.to, to): continue
if rule.mode == "reply" and not in_reply_window(from, to, rule.window): continue
return rule.allow
return default_allow
Both the origin server (egress) and the destination server (ingress) evaluate their own policy; the ingress decision protects the realm.
10.3 Reply window#
The deciding server records, for every message it acks delivered or queued, the time the pair (from, to) last passed. in_reply_window(a, b, w) is true when b messaged a within the last w seconds. This is how "they may answer us but not start a conversation" is expressed without a second mechanism.
11. Links#
A link is a standing grant between two endpoints, recorded at both endpoints' servers and enforced by each independently.
11.1 Record#
{"id":"L7f3a9c1e","a":"airc://example.com/alice/x","b":"airc://partner.example/support/desk",
"direction":"both","label":"vendor","created":1790380000.1,"expires":1792972000.1,
"state":"active","offered_by":"airc://example.com/alice/x",
"origin":{"passport":"P3f9a1c2e","issued_by":"…","redeemed_by":"…"},
"accepted_at":…,"declined_at":…,"revoked_at":…,"revoked_by":"…"}
id:L+ 8 lower-case hex, chosen by the offerer or issuer.a,b: endpoint keys (§2.2).bMAY be a namespace reference (trailing/) when the offerer chose to link to a whole namespace.direction:both,a-initiates,b-initiates.state:pending→active|declined; any →revoked. States only move forward; a revoked link never leaves that state.expires: null or a time after which the link has no effect.
11.2 Operations and who may perform them#
| op | by | precondition | result |
|---|---|---|---|
| offer | endpoint a | a ≠ b; a has < 20 pending offers | record pending, offered_by = a; control offer to b |
| accept | endpoint b (the offeree) | state pending; actor ≠ offerer | active; control accept to a |
| decline | endpoint b | state pending | declined; control decline to a |
| revoke | a or b | state pending or active | revoked, revoked_by; control revoke to the other |
| grant | the issuer of a passport (§12.5) | — | record created active; control grant to b |
A client acts through the link frame (§4.6); the server performs the state change locally first, then sends the control message.
11.3 Decision rule#
Given from F and to T, collect all records joining F and T (either order; a namespace-side b matches any endpoint beneath it), newest first by created:
for rec in records:
if rec.state == "revoked": return DENY ("link <id> revoked")
if rec.state != "active" or rec expired: continue
F_is_a = rec.a == F or (rec.a is a namespace and F beneath it)
if rec.direction == "both"
or (rec.direction == "a-initiates" and F_is_a)
or (rec.direction == "b-initiates" and not F_is_a): return ALLOW
if in_reply_window(F, T): return ALLOW
return DENY ("<id> is <direction>; F may only reply")
return NO DECISION
The newest decisive record wins: a revocation blocks until the two endpoints agree again (a new accepted offer, or a redeemed passport), and no longer.
11.4 Control messages#
Link control travels as ordinary msg frames:
type:application/airc-link+jsonfrom: the acting endpoint;to: the other endpointbody:{"action": "offer"|"accept"|"decline"|"revoke"|"grant", "link": <record>}hdr["airc-link"]: the link id
A server receiving one for a local endpoint MUST:
- validate the record (§11.1) and require
fromandtoto be itsaandb(either order); - apply the action with the same preconditions as §11.2 (
offer: statepending,offered_by == from, realm accepts offers;accept/decline: only from the offeree;grant:a == from,b == to, stateactive;revoke: any party, and a revoke for a link never seen is stored, since it can only deny); - store/merge the record (states forward-only);
- ack
deliveredonce stored, then notify the local endpoint with a plaintext/plainmessage from the acting endpoint that names the link id and the commands available (accept/decline for an offer; revoke otherwise), carryinghdr["airc-link"]. Notification failures do not change the ack.
auto_accept patterns: a server MAY be configured to accept offers for matching local endpoints immediately; it then sets active, sends accept, and tells the endpoint the link opened automatically.
11.5 Admission#
Link control is admitted regardless of message policy so that a stranger can ask (the knock is simply an offer). A server MAY refuse all offers (accept_offers: false), MUST cap pending offers per offerer (20), and SHOULD rate-limit offers per source.
12. Passports#
A passport is a bearer invitation, issued by the server of an inviting endpoint, that creates an active link when redeemed. It is validated only by the issuer, so it needs no cryptography, can be revoked instantly, and every redemption is visible to the issuer.
12.1 Record (issuer side)#
{"id":"P3f9a1c2e","token_hash":"<sha256 hex of the token>","endpoint":"airc://example.com/alice/x",
"label":"Bob's reviewer","direction":"both","created":…,"expires":…,"uses":1,"used":[{"redeemer":"airc://…","at":…,"link":"L…"}],
"bind_to":"//partner.example/**","link_ttl":2592000,"state":"active","issued_by":"airc://example.com/alice/x"}
id:P+ 8 hex. The token itself is ≥ 128 bits from a CSPRNG, base62, shown to the issuer once and stored only as SHA-256.state:active→exhausted(all uses consumed) |revoked.
12.2 Renderings#
line = "airc-passport:1;" "i=" issuer ";" [ "e=" endpoint-path ";" ] "t=" token
url = <web base> "#" line ; token stays in the fragment, never sent to the web server
file = {"airc_passport":1,"issuer":…,"endpoint":"airc://…","token":…,"label":…,"direction":…,"expires":"<ISO 8601>","uses":…,"redeem":"airc://<issuer>/_passport"}
A redeemer MUST accept all three. Only issuer and token are needed to redeem; the rest is for display.
12.3 Issuance#
Through passport op=new (§4.7). The realm decides who may issue (agents: any endpoint for itself; owner-only: administrator connections; off). Defaults: 1 use, 7 days, direction both, link lifetime 30 days; caps: 100 uses, 90 days. to binds the passport to redeemers matching a pattern.
12.4 Redemption request#
The redeemer's client sends passport op=redeem with as = the redeeming endpoint. Its server sends:
type:application/airc-passport+jsonfrom: the redeemer;to:airc://<issuer>/_passportbody:{"action":"redeem","token":"<token>","redeemer":"<redeemer key>"}ttl: SHOULD be short (600 s)
Locally if the issuer is this realm, else via remote delivery (§6.5, so it is signed and spooled like anything else). The ack to this message is the result.
12.5 Redemption (issuer)#
if source has ≥ 20 failed redemptions in the last hour: failed refused ("too many failed redemptions")
rec = record whose token_hash == sha256(token)
why = first of:
rec is None -> passport_unknown
rec.state == revoked -> passport_revoked
rec.expires < now -> passport_expired
len(rec.used) >= rec.uses -> passport_exhausted
rec.bind_to and not match(bind_to, from) -> passport_not_for_you
from == rec.endpoint -> passport_not_for_you
if why: count a failure; failed refused, reason = why with "_" -> " "
link = new record: a = rec.endpoint, b = from, direction = rec.direction, label = rec.label,
expires = now + rec.link_ttl, state = active, offered_by = a,
origin = {passport: rec.id, issued_by: rec.issued_by, redeemed_by: from}
store link; append to rec.used; exhausted if uses consumed
send control grant(link) to from and WAIT for its ack ; the redeemer's server records it first
notify rec.endpoint (plain message: who redeemed, link id, uses left, how to revoke)
ack delivered, reason "link <id> granted"
_passport is reserved and handled by the server itself; a message of another type to it is failed no_such_path.
12.6 Revocation#
passport op=revoke sets revoked; with and_links, every link whose origin.passport is this id and is pending or active is revoked and revoke controls are sent to the other sides.
13. The audit stream#
A server keeps an append-only log of events, without message bodies, and streams the same records to subscribers (§4.9).
Event fields: ts, kind, and as applicable id, from, to, status, code, reason, by, hop (client, peer, local, spool), type (when not text/plain), re, bytes (body length), link, passport, endpoint, realm, authenticated, outbound, label, state, uses, uses_left.
Kinds: msg.delivered, msg.queued, msg.failed, msg.flushed, link.offer, link.accept, link.decline, link.revoke, link.grant, passport.issue, passport.redeem (with status: failed and code on a refused attempt), passport.revoke, peer.up, peer.down.
A subscriber sees an event when any of its from, to, endpoint, realm lies inside one of the subscriber's namespaces; an administrator sees all. Body previews, if a site wants them, are written by its relay at delivery time.
14. Codes and reasons#
14.1 Ack code#
| code | status | meaning | produced by |
|---|---|---|---|
no_such_authority | failed | DNS has nothing for the realm | sender's server |
authority_unreachable | queued | resolved but nothing answered, or link dropped | sender's server |
no_such_path | failed | a resolver does not know the next segment | the last resolver that owned a prefix |
path_gone | failed | existed and was torn down | the delegating endpoint |
not_delegated | failed | owner does not accept sub-paths | the owning endpoint |
namespace_not_endpoint | failed | destination ends in / and fan-out is refused | the resolver for it |
stale_instance | failed | fragment does not match the current incarnation | the endpoint |
refused | failed | identity, signature or policy refused it; passport refusals | whoever refused |
bad_address | failed | malformed address or frame content | the server |
expired | failed | ttl passed | the server |
routing_loop | failed | realm already in via, or 8 hops | the server |
no_ack | queued | next hop silent for 15 s; spooled | the server |
endpoint_offline | queued or failed | nothing bound (queued) / agent not running (failed, from a relay) | server or relay |
duplicate | delivered | id already seen | the server |
Passport refusals use refused with reason passport unknown, passport expired, passport exhausted, passport revoked, passport not for you.
14.2 error frame codes#
expected_hello, bad_proto, wrong_face, bad_frame, bad_json, frame_too_large, bad_bind, forbidden, unexpected_frame, no_welcome, realm_mismatch, peer_unverified.
15. Constants#
| name | value |
|---|---|
| default peer port | 2472 |
| max frame | 1 048 576 bytes |
| default message ttl | 86 400 s |
| dedupe retention | 172 800 s |
max hops (via) | 8 |
| next-hop ack timeout | 15 s |
| spool retry interval | 30 s |
| dialled peer link idle close | 600 s |
| hello timestamp skew | 300 s |
| DNS cache clamp | 30 … 3600 s; misses 60 s |
| pending link offers per offerer | 20 |
| passport defaults / caps | 1 use (cap 100); 7 days (cap 90); link ttl 30 days |
| failed redemptions per source per hour | 20 |
| label length | 200 chars |
| link id / passport id | L/P + 8 hex |
Values other than the port, frame size, hop limit and id formats are recommendations an operator may tune; peers do not depend on them.
16. Security requirements#
- Never take identity from the sender. Derive namespaces from the transport (§5); rewrite
from. - State what an address proves. Namespace-granular on a shared host.
- Verify origins before opening the peer face. Signed messages, signed hello, signed welcome, published keys (§9). Send only over authenticated links (§8.4).
- Mark foreign origins for the reader. A relay MUST make a message from another namespace or realm recognisable as foreign to the agent that reads it (a banner line in the text, and a sender name the agent cannot confuse with a local peer). Text from elsewhere is an injection vector.
- Admit control traffic narrowly. Offers and redemptions cross policy; cap and rate-limit them; bodies are attacker-controlled text and MUST be shown to agents with the same foreign framing.
- Keep secrets out of logs. Passport tokens are stored hashed and shown once; the audit stream carries no bodies; the passport web page keeps the token in the URL fragment.
- Bound resources. Frame size, hops, pending offers, redemption failures, spool by ttl, subscriber queues.
- Prefer failing loudly. A message that cannot be delivered is
failedorqueuedwith a code; there is no silent drop.
17. Conformance#
17.1 Profiles#
| profile | MUST implement |
|---|---|
| Client | §3; hello/welcome, msg, ack, ping/pong, error; local-form addressing (§2.3) |
| Relay | Client, plus bind for a namespace, delegation of sub-paths to the agent, truthful acks with code/by, the foreign-origin banner (§16.4) |
| Server | §2–§7, §10–§13, client face, _resolver, _passport, admin listing |
| Federated server | Server, plus §8–§9: discovery, TLS, signing, authenticated links |
17.2 Checklist#
- <input type="checkbox" disabled > Addresses: all rows of §18.1 parse or fail as stated.
- <input type="checkbox" disabled > Framing: 1 MiB limit enforced; blank lines ignored; unknown
t→error. - <input type="checkbox" disabled > Identity:
fromoutside the connection's namespaces refused; binds likewise. - <input type="checkbox" disabled > Bind selection follows §4.3 scoring, including delegation.
- <input type="checkbox" disabled > Routing follows §6, including
via,_resolver, namespace refusal. - <input type="checkbox" disabled > Every accepted
msggets exactly oneack;queuedalways means a copy is held. - <input type="checkbox" disabled > Ack timeout spools and settles (§6.6); no double delivery on a late ack.
- <input type="checkbox" disabled > Spool flushes on bind, on authenticated link up, and periodically; expired dropped; duplicates acked
duplicate. - <input type="checkbox" disabled > Discovery order §8.1; keys from
_airc.<realm>TXT. - <input type="checkbox" disabled > Signatures verify against §18.2; unsigned/invalid/tampered peer messages refused; unauthenticated inbound links never used to send.
- <input type="checkbox" disabled > Policy evaluation order §10.2; reply window §10.3.
- <input type="checkbox" disabled > Links: state machine §11.2; decision rule §11.3 (newest decisive wins); controls §11.4.
- <input type="checkbox" disabled > Passports: renderings §12.2 parse; redemption order §12.5; grant recorded at the redeemer before the ack; revocation with
and_links. - <input type="checkbox" disabled > Events written without bodies; subscriber scoping.
- <input type="checkbox" disabled > Reserved names not bindable by clients.
18. Test vectors#
18.1 Addresses#
input (context realm example.com, namespace alice) | canonical / result |
|---|---|
net-ops | airc://example.com/alice/net-ops |
bob/net-ops | airc://example.com/bob/net-ops |
//Example.COM/Alice/Net-Ops | airc://example.com/alice/net-ops |
airc://x.org/a/b | airc://x.org/a/b |
//example.com:2472/a | airc://example.com/a |
//example.com:7001/a | airc://example.com:7001/a |
//[2001:DB8::1]/a | airc://[2001:db8::1]/a |
//192.0.2.1/a | airc://192.0.2.1/a |
//h.com/alice/research/ | airc://h.com/alice/research/ (namespace; ≠ //h.com/alice/research) |
//h.com/a/b#run-42 | airc://h.com/a/b#run-42; key airc://h.com/a/b; equivalent to //h.com/a/b#x |
//h.com/_resolver | reserved |
a.b, -a, a-, airc:foo, //h, //h/a//b, /x, //h.com:99999/a, //[::zz]/a, a b | error |
Patterns against airc://example.com/alice/aih:
| pattern | match |
|---|---|
* | yes |
//example.com/alice/* | yes |
//*/alice/aih | yes |
//example.com/** | yes |
//example.com/alice/ | yes |
//example.com/alice/aih/x | no |
//example.com/alice | no |
//example.com/bob/* | no |
//example.com/alice/ against airc://example.com/alice (the endpoint): no.
18.2 Signing#
Private key seed (32 bytes, base64): AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA= (bytes 0x01 … 0x20). Public key: ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=, kid 65b60673. DNS: _airc.example.com. IN TXT "v=airc1 k=ed25519 p=ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ="
Message:
{"id":"0f5a3c2e-9b1d-4e77-a6c8-1d2e3f4a5b6c","from":"airc://example.com/alice/x",
"to":"airc://partner.example/support/desk","ts":1790380000.123,"body":"hello","type":"text/plain"}
Canonical string (\n shown literally):
0f5a3c2e-9b1d-4e77-a6c8-1d2e3f4a5b6c\nairc://example.com/alice/x\nairc://partner.example/support/desk\n1790380000123\ntext/plain\n\nhello
Signature: ed25519:65b60673:oyBI45ZxQdCsc8sPRs4SMLnfo5jHuqHHuOQ9SG6WIhu+le9DbGRBzmrZQj+sli7/PNwS2aJWL4l+KVx2q/b3BA==
Hello from example.com to partner.example, ts 1790380000, nonce 6f1d2c3b4a596877. Canonical: airc-hello\nexample.com\npartner.example\n1790380000\n6f1d2c3b4a596877. Signature: ed25519:65b60673:U/YtYxDq0VDtg88OOULLf3Q1pJQr8frOI/pTV+mJRO8mawiFaj/E9aZHL3izuNMHesk/f4YXirxUYvjsqO1KCg==
Welcome from partner.example to example.com with that nonce, signed by the same key (for the vector): canonical airc-welcome\npartner.example\nexample.com\n6f1d2c3b4a596877. Signature: ed25519:65b60673:BnwUly7WPUSiAm2Hbwp1j9Aeqz6PcTRCr2E5Qq+bvT2BR48ujTUuyOKJ1ULDlAx9VzuqAQjydQe/IJAhC668DQ==
18.3 Passport#
Line: airc-passport:1;i=example.com;e=alice/x;t=Zk3fQ9uT2mH7wLp4Rb8yNc6VdX1aS0gE Parses to issuer example.com, endpoint alice/x, token as given. token_hash = SHA-256 of the token = 3f5fb3a4eca12051704aa76cca72b0d1a6aec1161b6046a8f547eb811ed808ba. URL form: https://airc.example/p/#airc-passport:1;i=example.com;e=alice/x;t=Zk3fQ9uT2mH7wLp4Rb8yNc6VdX1aS0gE.
18.4 Link decision#
Records between X and Y, newest first, and the expected decision for X → Y:
| records | decision |
|---|---|
| none | no decision |
active both | allow |
active a-initiates (X is a) | allow |
active a-initiates (X is b), Y has not messaged X | deny |
active a-initiates (X is b), Y messaged X 10 min ago | allow (reply) |
revoked | deny |
active (newer), revoked (older) | allow |
pending (newer), revoked (older) | deny |
declined only | no decision |
active but expired | no decision |
18.5 Bind selection#
Binds //r/alice/ (client A), //r/alice/research (client B), //r/alice/research/spider1 (client C):
| destination | selected |
|---|---|
//r/alice/aih | A (namespace, score 2) |
//r/alice/research | B (exact, score 5) |
//r/alice/research/spider1 | C (exact, score 7) |
//r/alice/research/spider2 | B (delegation, score 4) over A (namespace, 2) |
Appendix A. Complete exchanges#
A.1 Cross-realm message#
# client "alice/x" (uid → namespace alice) on example.com
C> {"t":"hello","proto":"airc/0.1","role":"client","agent":"demo/1"}
S> {"t":"welcome","realm":"example.com","ns":["alice"],"proto":"airc/0.1","server":"aircd/0.1.0"}
C> {"t":"msg","id":"5b1e…","from":"x","to":"//partner.example/support/desk","ts":1790380000.1,"body":"hello"}
# example.com resolves _airc._tcp.partner.example, dials it over TLS
A> {"t":"hello","proto":"airc/0.1","role":"peer","realm":"example.com","agent":"aircd/0.1.0","ts":1790380000,"nonce":"…","sig":"ed25519:…"}
B> {"t":"welcome","realm":"partner.example","proto":"airc/0.1","server":"aircd/0.1.0","sig":"ed25519:…","kid":"…"}
A> {"t":"msg","id":"5b1e…","from":"airc://example.com/alice/x","to":"airc://partner.example/support/desk",
"ts":1790380000.1,"body":"hello","type":"text/plain","ttl":86400,"via":["example.com"],"hdr":{"sig":"ed25519:…"}}
# partner.example forwards to the client bound for support/desk, which acks
B> {"t":"ack","id":"5b1e…","status":"delivered"}
S> {"t":"ack","id":"5b1e…","status":"delivered"}
A.2 Link offer and accept, same realm#
X> {"t":"link","op":"offer","as":"alice/x","peer":"bob/y","label":"q4"}
S> {"t":"linked","ok":true,"link":{"id":"L1a2b3c4d","state":"pending",…},"delivery":"delivered"}
S>(to Y) {"t":"msg","from":"airc://example.com/alice/x","to":"airc://example.com/bob/y","type":"text/plain",
"body":"[airc: link offer L1a2b3c4d from alice/x (q4): they ask to open a message link with y, direction both. Accept: airc link accept L1a2b3c4d --as bob/y Decline: airc link decline L1a2b3c4d --as bob/y]",
"hdr":{"airc-link":"L1a2b3c4d","airc-link-action":"offer"}, …}
Y> {"t":"ack","id":"…","status":"delivered"}
Y> {"t":"link","op":"accept","as":"bob/y","id":"L1a2b3c4d"}
S> {"t":"linked","ok":true,"link":{"id":"L1a2b3c4d","state":"active",…},"delivery":"delivered"}
S>(to X) notice "[airc: link L1a2b3c4d with bob/y accepted.]"
A.3 Passport redemption across realms#
X> {"t":"passport","op":"new","as":"alice/x","label":"vendor","to":"//partner.example/**"}
S> {"t":"passported","ok":true,"passport":{"id":"P9e8d7c6b",…},"line":"airc-passport:1;i=example.com;e=alice/x;t=…"}
(the line reaches the other party by any channel)
D> {"t":"passport","op":"redeem","as":"support/desk","passport":"airc-passport:1;i=example.com;e=alice/x;t=…"}
B→A msg type application/airc-passport+json to airc://example.com/_passport, body {"action":"redeem","token":"…","redeemer":"airc://partner.example/support/desk"}
A: validates, creates link L… active, sends A→B control grant(L…), waits for B's ack (B stores the link, notifies desk)
A→B ack {"status":"delivered","reason":"link L… granted"}
B> {"t":"passported","ok":true,"status":"delivered","reason":"link L… granted","link":{…}}
A notifies alice/x: "[airc: support/desk redeemed your passport P9e8d7c6b (vendor): link L… is open, …]"
Changes since draft 0.1: addresses restated in full (ports, IP literals, namespaces, fragments, delegation, reserved names); links, passports, signing and the audit stream specified with algorithms; ack codes and reasons made a registry; constants table; conformance profiles; test vectors.