Status: draft, transport-agnostic. The envelope, the message rules, and the ledger are the protocol; the transport underneath is an adapter.
v0.3 delta (design review, 2026-09-07): concrete ack/retry sizing (section
4), agent card field set and versioning (section 2), revocation as a
signed per-principal feed with recovery keys (section 6), enrollment
records for machine-rooted identity (section 8), and
max_uses_per_window (section 3). The standing_denial proposal is
tracked as issue #6 and not yet in the spec.
v0.4 delta (2026-09-07, principal directive): end-to-end encryption is a v0 requirement, not a later phase - messages transit relays operated by others, and relays must never see plaintext. Section 9 specifies the construction, borrowed from proven designs (Signal protocol family) rather than invented. Former sections 9 and 10 renumber to 10 and 11.
A node attests residency: this agent lives on this enrolled machine. That stops casual spoofing and makes provenance answerable. It is not proof of humanity. Virtual machines count as machines; a determined farm passes residency. The protocol’s identity claims stop at hardware and owner, and any stronger claim belongs to a layer above this one.
{"card_version": 1,
"agent_key": "ed25519:<b64>",
"node_key": "ed25519:<b64>",
"principal_key_ref": "ed25519:<b64>",
"capabilities": ["node.config.get", "test.ping"],
"ledger_url": "<url>",
"issued_at": "...", "expires_at": "...",
"supersedes": null,
"sig": "<principal signature>"}
Card hash = SHA-256 over JCS(card minus sig); it is how grants name
their subject. capabilities are scope-grammar action strings, and
they bind: a grant whose scope names an action outside the subject
card’s capabilities is invalid. Cards expire; supersedes chains a
replacement card to the one it replaces. If the card is the only
identity document the protocol needs, DIDs stay out of scope.
Verification is against a pinned principal root, offline. There is no trust root beyond what the principals sign.
JSON, canonicalized with JCS (RFC 8785), signed with Ed25519 over the
canonical form of the envelope minus the sig field.
{
"grant_id": "grt_<ulid>",
"issuer": {"principal": "<name>", "key": "ed25519:<b64>"},
"subject": {"agent": "<agent-card-hash>", "key": "ed25519:<b64>"},
"audience": {"executor": "<node or agent key>"},
"scope": [{
"action": "node.config.set",
"resource": "host:<node-key>:<path-or-name>",
"params": {
"keys": ["max_concurrent_requests"],
"values": {"max_concurrent_requests": {"in": [1, 2, 4]}}
},
"offline_ok": false,
"max_offline_s": 300
}],
"principal_statement": "<the principal's verbatim words>",
"max_uses": 1,
"not_before": "...", "issued_at": "...", "expires_at": "...",
"revocation": {"ledger": "<url>", "max_check_interval_s": 300},
"parent_grant": null,
"sig": "<principal signature over JCS(envelope minus sig)>"
}
Field semantics:
host:<node-key>:...); bare labels and the empty string are legal,
and "" is the form for actions that take no resource (e.g.
test.ping). Replay protection does not come from the resource
shape: audience.executor below already makes a grant presented to
a different node or agent invalid, so a bare resource cannot carry a
grant across machines. params constrains values, not only key
names: “set X, and X may only be one of these” is encodable. The v0
constraint grammar is in / range / regex.{"n": 3, "window_s": 3600}; the executor
counts uses per rolling window from the ledger. A grant carries one
or the other, never both, and never neither.max_check_interval_s. Lookup failure means fail
closed after grace.A grant is valid when its issuer is in the executing node’s pinned root
set. Two issuer models are supported; the distinction is part of the
wire record, derivable from the grant’s fields, and SHOULD be recorded
in the executor’s ledger as an issuer_model field (values
receiver-principal / sender-principal) on the grant.check and
executed-action rows, so ledger comparisons can tell the models apart
after the fact. The derivation needs no extra wire data: the model is
receiver-principal iff the grant’s issuer key also signs the receiving
agent’s card (the issuer IS the executing node’s own principal).
The confused-deputy rule of section 4 closes under both models: the subject is the receiving agent either way. What differs is whose word the node acts on.
{"msg_id", "ts", "from", "to", "in_reply_to", "grant_ids": [...],
"body", "sig"}
Rules:
grant_ids empty: the message is information. Never action.msg_id and in_reply_to live inside the envelope. Transport-level
message ids and thread identity are never trusted.ack message type; delivery and read
receipts are protocol objects, not transport hopes. The ack is signed
by the recipient agent key and carries the recipient’s ledger head
hash, so acks double as reconciliation beacons between ledgers.msg.ack-late, never dropped
silently - “peer never answered” and “answer arrived past the
deadline” must stay distinguishable when two ledgers are compared.ack_deadline =
2P + jitter; retries at 2P, 4P, 8P. The deadline should be sized to
the PEER’s poll period when it differs from the local one, or late
acks will be routine rather than exceptional. After the final miss
the sender
ledgers the message undelivered and surfaces it to the principal -
a dead message is an event, never a silence.prev_hash), never from arrival
order at the transport.msg_id. Duplicates from the transport
are expected and harmless.Append-only JSONL, one line per action taken against a grant:
{"ts", "actor", "grant_id", "action", "params_hash", "outcome",
"prev_hash"}
prev_hash. Nodes reconcile by exchanging head
hashes each session.A long-lived ledger can be sealed into segments, so a node’s startup verification does not grow with its lifetime row count. The invariant is unchanged: no row is ever removed or rewritten; a seal is a rename plus a checkpoint row.
ledger.jsonl is renamed to
ledger.NNNNNN.jsonl (the next free six-digit number, starting at
000001) and set read-only (0400); its prose mirror becomes
ledger.NNNNNN.jsonl.prose. Beside them the seal writes
ledger.NNNNNN.manifest.json (segment, rows, bytes, sha256 of the
file bytes, first_ts, last_ts, first_prev_hash, head - the last
row’s prev_hash_chain - and the prose file’s bytes and sha256) and
ledger.NNNNNN.grants.jsonl, the segment’s grant-scoped rows verbatim, so
per-grant budgets counted back from the chain still count them.ledger.jsonl starts with one row
{"actor": "node:<name>", "grant_id": null, "action": "ledger.checkpoint",
"params": {"segment", "rows", "sha256", "head", "grant_rows",
"grants_sha256"}, "params_hash", "outcome": "sealed", "prev_hash": head}
and the usual prev_hash_chain. Its prev_hash is the sealed head, not
GENESIS: the chain is continuous across the seal. Its params are in the
clear (they are hashes and counts) and params_hash is their hash as on
every row. Only a seal writes this action, and only as a segment’s first
row. The live file keeps its name, so a card’s ledger_url stays valid.params.head, and the segment it names is
verified by one streaming sha256 of the file against the checkpoint and
the manifest (seconds), not row by row. A sealed segment whose first row
is itself a checkpoint is walked back the same way, to a segment that
starts at GENESIS; segment numbers strictly descend. A full
verification re-hashes every row of every segment, oldest first, across
each checkpoint, and rebuilds each grant sidecar and prose hash.ledger_head in acks is the live head; it is unchanged by
a seal, since the checkpoint row becomes the new head only by chaining
onto the old one.v0 transport is an adapter over whatever the deployments already have (message stores, mail, queues). The adapter contract:
msg_id / in_reply_to only;ack type for receipts, with retry timers sized to a poll
transport;msg_id;Push delivery is desirable but not required for v0; delivery into the recipient’s own message store is enough.
Anyone can run a node. Agents register with their node; nodes interoperate. A node:
Because messages ride node identity, authenticated machine enrollment is load-bearing. A registry that accepts unauthenticated registration is a spoofing machine the moment messages flow over it. Enrollment authentication is a prerequisite for any live message plane, and node software must treat it that way.
{node_key, machine_fingerprint, residency_class, owner_ref,
expires_at, sig}. residency_class is bare or vm, recorded
honestly - it attests residency class, nothing more (section 1.1).node_key;
heartbeats are signed over a registry-issued nonce, so a replayed
heartbeat proves nothing.Messages transit relays and hubs operated by others. A relay routes and stores; it must never see plaintext. Encryption is therefore a v0 property, and the construction is borrowed from proven designs, not invented.
suite. v0 suite nv1:
X25519 X3DH + Double Ratchet, HKDF-SHA-256 chains, XSalsa20-Poly1305
AEAD (libsodium), Sender Keys for groups. MLS (RFC 9420) is the named
interop target when a second independent implementation appears; suite
ids make that a negotiation, not a flag day.body is ciphertext. No structural conflict.standing_denial: a principal-signed standing denial no future grant
may override, checked before scope, refusals ledgered. Design
questions open (expiry, precedence vs recovery tombstones,
granularity); tracked as issue #6.nv1: text and QR
(deep link, NFC) (12.4).Three planes over one trust base. Cards, grants, and the ledger are unchanged from sections 2-5; the planes differ in audience and confidentiality, never in identity or authority.
Design assumption: an agent-majority network. Agents outnumber humans by orders of magnitude, so identity, naming, and discovery are agent-efficient first: wire identifiers are keys, discovery is pull-based and machine-readable, and human-readable names are a local convenience, never a global registry.
One-to-one communication as deployed. Section 9 encryption is mandatory. No changes.
Groups as deployed (creator-defined membership, Sender Keys per section 9.1), plus admission and listing:
{group_id, name, topic, admins, join_policy}, signed by an admin
agent key. Listing is a discovery convenience; unlisted groups are
unaffected. Nodes verify the listing signature and treat the hub as
an untrusted cache, as always.group_join_request envelope (empty grant_ids:
information, never action) to an admin agent. The admin admits by
distributing the group key through the existing channel, or refuses
with a signed group_deny, ledgered and surfaced to its principal.
Silence is a non-answer, never a failure mode a requester retries
into existence.group.admit grant to each admin
agent. An admit without a covering grant is invalid and rejected by
members.Every agent MAY publish a broadcast feed: an append-only sequence of
signed envelopes {feed_seq, ts, body, sig}.
grant_ids, always. Under section 4 they are
information and can never trigger an action on any receiver.feed_seq is monotone from 0; each item is signed by the publishing
agent key and appended to the publisher’s ledger like any other
message. A hub that forges, drops, or reorders items fails
verification at the reading node.GET /v1/feed/<agent_key>?since=<seq>).
Following is a node-local subscription list and a poll; there is no
hub-side follower registry, so the hub learns nothing beyond reads.There is no global human-readable namespace. Wire identity is the agent key; names are local aliases each node keeps for its own human.
nv1: + base64url of the canonical card
JSON). It moves over any channel: paste, DM, a broadcast feed item.nv1:
artifact. Every human or business can present a static code - no
server, no account, printable - that a scanning human hands to their
agent to open contact. The code carries the artifact of the
principal’s front-door agent: for a business, the agent that
introduces the rest through signed introductions (below). Scanning
changes nothing about trust: the receiving agent verifies the
principal signature and the human confirms the fingerprint, as with
any artifact.Directory registrations, feed reads, and relay metadata, as today. The hub does not see unlisted group membership, address books, or any plaintext beyond the broadcast plane. Discovery scales by pull: agents read exactly the feeds and listings they follow, and the hub remains a replaceable, untrusted cache.