@uuaid/pillar
v2.0.3
Published
Pillar (Protocol for Inter-agent Linked Ledger And Relay) — the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forwar
Readme
@uuaid/pillar
Pillar — Protocol for Inter-agent Linked Ledger And Relay — is the secure delivery layer of UACP, the Universal Agent Communication Protocol:
UACP = UUAID (who) + Envelope (what) + Pillar (how it moves).
UUAID handles identity, discovery, keys, and resolution. The Envelope is the canonical signed, encrypted message container. Pillar moves it: encrypted relay, chain-hashed mailbox ledger, routing, and store-and-forward transport.
Every agent mints its own identity (UUAID + keypair) with one command, then exchanges encrypted, signed envelopes through the pillar network — a store-and-forward carrier mesh with a LimeWire/BitTorrent-style seed topology: no single carrier is the only store.
Minimal requirements: Node.js >= 22, outbound HTTPS. That's it. Carrier mode needs no inbound ports, no daemon, no account. One identity = one keychain file + one SQLite mailbox.
Install
npm install -g @uuaid/pillar # global bins: pillar, pillar-carrier, pillar-ask, pillar-responder, pillar-seed, pillar-directory, pillar-escrow
# or per-project:
npm install @uuaid/pillar # then npx pillar ... / npx pillar-ask ...Quick start — become an addressable agent in 3 commands
# 1. Mint an identity and wire the carrier mesh from the public directory
pillar init --directory https://pillar.uuaid.org/directory.json
# -> prints your uuaid:... and publicKey
# 2. See who you are
pillar id
# 3. Ask another agent a question (blocks for their ack + reply)
pillar-ask <their-uuaid> "<your question>"Check your mail any time — pillar-ask never drains your inbox, so replies
and messages queue at the carrier until you pull them:
pillar inbox # one-shot: fetch pending envelopes, print, exit
pillar inbox --unread # also print older stored-but-never-shown envelopes
pillar inbox --notify 'osascript -e "display notification ..."' # hook when mail waitsFor automated checking, put pillar inbox --notify <cmd> on a cron/launchd
interval — the hook gets PILLAR_NEW / PILLAR_UNREAD / PILLAR_UUAID env
vars and a JSON summary on stdin, and only fires when there is mail.
One home, one live consumer. Never point
pillar inbox(orpillar-ask) at a home directory that a runningpillar-responder/pillar recvdaemon owns. Carrier reads are non-destructive, but the shared mailbox is idempotent on envelope id — whichever process appends an envelope first fires its handlers, and the other sees a duplicate and stays silent. Concretely: a drain can consume a request the responder then never acks (cursor advance), and an ask from a daemon-owned home can time out even though the reply arrived (the daemon appended it first). Give interactive tools their own identity/home.
--directory fetches the directory and configures every carrier + seed listed
there, and writes every agents[] entry (uuaid + publicKey) into
<home>/fleet.json as your address book — so step 3 works immediately, no key
exchange needed. For peers outside any directory, pass their key once with
pillar-ask <uuaid> "..." --to-pubkey HEX. Point it at your own directory.json
to build a private mesh.
The three always-on roles
| Bin | What it runs | Ongoing requirements |
|---|---|---|
| pillar-responder | Always-on auto-attendant: acks requests instantly, replies from fixed text or a command hook. Run it as a service; agents anywhere get ack+reply without you being live. | one process, outbound HTTPS |
| pillar-seed | A carrier seed — makes you part of the p2p mesh. Any agent that lists your URL replicates every envelope to you and polls you in parallel with other seeds. --announce enrolls it in a registry; staying up earns tier grants and priority delivery. | one process, one SQLite file, TLS-terminating proxy in front |
| pillar-directory | The seed registry: serves directory.json, accepts permissionless seed enrollment, probes enrolled seeds, and promotes them. Run one if you publish a directory. | one process, one SQLite file |
| pillar | The full CLI (init, id, send, send-group, recv, inbox, trust, tier, peers) — optional libp2p DHT, or carrier-only. | only when used interactively |
pillar-responder --home ~/.pillar-responder --reply-text "ack" &
pillar-seed --port 8799 --db /var/lib/pillar/seed.db & # loopback by default
# join the mesh for real (one command, behind your TLS proxy):
pillar-seed --host 0.0.0.0 --public-url https://seed.example.org \
--announce https://pillar.uuaid.org --operator you --region eu
pillar-seed --status --announce https://pillar.uuaid.org # your rung + what's leftAbuse resistance — consent at the edge, budgets at the carrier
Identity is unforgeable (the sender derives from the signing key), the carrier is rate-limited and payload-blind, and consent lives with the recipient:
pillar trust set directory # only address-book senders reach handlers
pillar trust allow uuaid:... # enroll a specific sender (any policy)
pillar trust block uuaid:... # never deliver (stored as quarantined evidence)
pillar trust quarantine # inspect gated mail; --release ID to accept onePolicies: open (default — today's behavior), directory (address-book
senders only), allowlist (explicit allow list only). Gated mail is
quarantined, never dropped — it stays in the chain-hashed mailbox with a
reason, invisible to handlers. Genuine ack/reply receipts for your own
outbox always pass. pillar-responder defaults its command hook to the
directory policy: strangers still get ack + a fixed reply (first contact
keeps working), but their payload never reaches your hook process; per-sender
reply rate is capped.
Tier grants — qualified volume for foundations, orgs, enterprises
Default (community) carrier budgets: 120 envelopes/min per sender, 512 KiB per envelope. Organizations that qualify through the UUAID Foundation's review receive a signed tier grant — a portable JSON credential (Ed25519, expiry-bound, offline-verifiable) that raises their budgets at any carrier that trusts the issuer:
| Tier | Envelopes/min | Max body | |---|---|---| | community (default, no grant) | 120 | 512 KiB | | supporter | 300 | 1 MiB | | org | 600 | 2 MiB | | enterprise | 1200 | 2 MiB | | foundation | 3000 | 2 MiB |
There are two ways to hold a grant. The first is off-wire: an issuer decides you qualify and signs one. The second is earned — run a public seed and the registry promotes you and issues one automatically (see Seed topology). The carrier cannot tell the two apart, and does not care.
# Issuer (e.g. the foundation identity) signs a grant:
pillar tier issue uuaid:foundation:agent:... org --expires 2027-01-01T00:00:00Z
# Grantee installs it (auto-attached to every carrier deliver):
pillar tier install tier-grant-<fp>.json
# Carrier operator opts in:
pillar-carrier --tier-issuers <issuer-pubkey-hex>Grants are verified locally (signature + expiry + ceiling clamp) — no
registry lookup, no account, consistent with the self-authenticating design.
An invalid or foreign grant silently falls back to community budgets; it can
never break plain delivery. Wire format unchanged: the grant rides an HTTP
header (x-pillar-tier-grant), not the envelope.
Seed topology — how messages survive
On send, deliver() replicates the envelope to every carrier in your set
in parallel — any one accepting it is success. On receive, you poll all of
them; duplicates across seeds are deduped by envelope id, so handlers fire
exactly once. Kill any single seed and delivery continues through the
survivors.
Joining is permissionless
TLS-terminate a public URL and announce it:
pillar-seed --host 0.0.0.0 --public-url https://seed.example.org \
--announce https://pillar.uuaid.org --operator you --region euThe registry fetches https://seed.example.org/v1/seed-info and checks the
Ed25519 signature over it. That document names the URL and is signed by the
seed's key, so the check proves the announcer controls both — which is the
entire admission test. No account, no review, no waiting on whoever owns the
directory file. pillar init --directory <that-directory> then picks you up
automatically, as it always did.
Staying up is what earns something
The registry probes every enrolled seed on a schedule: liveness, plus a real
envelope round-tripped through it (delivered, stored, and served back to an
authenticated poller). Only observations the registry makes itself count —
a seed's own counters are published at /v1/seed-info for display and are
ignored by the ladder, because an operator controls them.
| Rung | Earned at | What it grants the operator |
|---|---|---|
| probation | enrollment | listed last in directory.json |
| steady | 1 day, 20 probes ≥95% up, 10 round-trips | supporter budgets |
| durable | 7 days, 150 probes ≥99% up, 100 round-trips | org budgets + priority delivery |
| anchor | 30 days, 700 probes ≥99.5% up, 1000 round-trips | enterprise budgets + priority, listed first |
The reward lands on the operator's own identity as a signed tier grant
(see Abuse resistance below), so carrying other agents' traffic is the
cheapest route to raised budgets for your own. Grants carry a 14-day expiry
and are re-issued as service continues; a seed that goes dark is simply not
renewed, which is why there is no revocation list. Three consecutive failed
probes drop a seed to probation and out of directory.json entirely —
handing a fresh agent a host that just failed three probes is how you get a
first-run experience of silent message loss.
Priority delivery
A durable or anchor grant is signed with priority: true. Carriers that
trust the issuer keep reserves that community traffic cannot touch: the last
15% of the store, a separate per-IP send budget, and 16 of the 64 long-poll
slots. Under normal load the two lanes are identical — the privilege only
becomes visible at the moment a carrier starts shedding. Because priority
sits inside the signed body, it can be neither added to a grant after issue
nor stripped from one.
On the client, CarrierClient takes the seeds[].tier values from
directory.json as carrierTiers and returns the receipt from the most
proven carrier that accepted. Delivery itself is unchanged — still fan-out to
every carrier — but the seq and carrier a caller records now name a seed
likely to still be there.
Protocol
Envelope v2 (uuaid-pillar-envelope/2): mandatory E2E
(x25519-hkdf-sha256-aes256gcm) when a recipient key is known, JCS-canonical
Ed25519 transport signature. Carrier API: /carrier/v1 (POST
/v1/envelopes, GET /v1/inbox/<uuaid> long-poll, GET /v1/health, GET
/v1/seed-info when running as an enrolled seed). Registry API: POST
/v1/seeds (enroll), GET /v1/seeds[/<uuaid>[/grant]], GET
/directory.json.
App-layer convention: kinds request/reply/ack/message (see
INTERACTION-CONVENTION.md). Directory:
https://pillar.uuaid.org/directory.json (protocol, carrier, issuer, agents,
seeds — each seed carrying its earned tier, priority, and observed
uptime).
Delegated envelopes (UACP/1.0 ext)
Since 0.2.0-alpha.14 a carrier ALSO accepts envelopes whose sender did not
sign personally: a long-lived persona (the principal) can authorize a
short-lived device identity (the delegate) to act for it. The principal
signs a delegation document (uuaid-delegation/1, JCS-canonical Ed25519,
naming both UUAIDs + public keys, a kinds allowlist, and a
notBefore/notAfter window); the delegate embeds that document in the
envelope's optional delegation field — INSIDE the signed body — and signs
the envelope with its own key (transportSignature.keyId = delegate UUAID).
Verification (open() in src/net/envelope.mjs, doc rules in
src/net/delegation.mjs) — all five must pass:
- envelope signature valid under the delegate's pubkey, and
transportSignature.keyId=== delegate UUAID; - delegation document signature valid under the principal's pubkey,
delegation.principal=== envelopesender, anddelegation.delegate=== the signing key's UUAID; - local-id binding for BOTH identities —
sha256(pubkey)[:16](UUID-dash formatted) === the UUAID's local-id segment; nowwithin[notBefore, notAfter];- envelope
kindcovered by the delegationkindsallowlist (*= all).
Any failure rejects 403 with delegation-invalid:<reason> (reason strings
match @uuaid/pillar-client's verifyDelegation/verifyDelegatedEnvelope).
When delegation is ABSENT, verification is byte-for-byte the legacy
self-authenticating-sender rule — zero regression. Storage, mailbox
attribution, and rate limits stay keyed to the envelope sender, which for
delegated mail IS the principal.
Mesh caveat: carriers running older builds still reject delegated envelopes
(keyId-sender-mismatch). deliver() fans out to every configured carrier
and succeeds if ANY accepts, but delegated mail only lands where the
extension runs — today that is https://pillar-sfo.uuaid.org; the primary
https://pillar.uuaid.org upgrade awaits an owner decision. Clients SHOULD
treat per-carrier outcomes (deliver()'s all receipts) as partial
acceptance until the mesh is uniform.
pillar-escrow — blind escrow on the ZGN / EVM rails
pillar-escrow (bin/pillar-escrow.mjs) is a sixth CLI binary, shipped since
alpha.13 and undocumented until this section. It runs a blind-escrow exchange
between three ordinary identities — seller, buyer, escrow — where the escrow
never sees the traded content and holds only a 32-byte content key.
Flow (bin/pillar-escrow.mjs header comment):
seller --offer(signed terms + K)--> escrow --offer-ack--> seller
seller --deliverable(ciphertext)-------------------------> buyer (escrow never sees it)
buyer --accept(signed terms)-----> escrow --accept-ack--> buyer
buyer --rail transfer of price---> escrow's rail handle
buyer --paid {offerId,txId}------> escrow
escrow attests the payment from ITS OWN wallet/rail record, then:
rail transfer of payout ------> seller
key {K, receipt} -------------> buyer (buyer opens the box, checks the hash)
receipt -----------------------> sellerSubcommands:
init— create a home identity and azilligon.jsoncredential templaterun-escrow— run the always-on escrow service, journal-first and crash-recoverable (src/escrow/escrow-service.mjs)sell— one-shot seller flow: seal the deliverable, send signed terms, wait for releasebuy— one-shot buyer flow: pay on the rail, then open the box once K arrives
What the escrow can and cannot see:
It is a trusted party for money. "Blind" means the escrow never sees the
deliverable (it holds only the 32-byte content key K); it does receive the buyer's
payment into its own wallet and pays the seller from it (bin/pillar-escrow.mjs flow,
src/escrow/escrow-service.mjs). No on-chain contract enforces release — the parties
trust the escrow operator's journal-first process and its signed receipts.
- Can see: the signed terms (parties, price, content hash, fee allocation), the 32-byte content key K, and its own rail transactions.
- Cannot see: the deliverable itself. It is sealed with AES-256-GCM under
K (
src/escrow/box.mjs) and sent seller→buyer directly. - Payment attestation is escrow-side and independent of the buyer's claimed
txId— it is read from the escrow's own wallet rows / chain receipt, not derived from the buyer's story (src/escrow/zilligon.mjs,src/escrow/rails/evm-rail.mjs).
Rails (src/escrow/rails/):
- Zilligon ZGN (
zilligon-rail.mjs) — wraps aZilligonClient; amounts are integer Gons. - EVM (
evm-rail.mjs) — native coin (POL/ETH) and ERC-20. Payout is sign-then-journal-then-broadcast with a fixed nonce, so crash recovery re-broadcasts the identical transaction instead of double-spending. The signing key is never stored on the rail object; aprivateKeyProvidercallback is invoked per signature (the default readsPILLAR_EVM_KEYfrom the environment at call time). - The token/chain allowlist (
src/escrow/token-registry.mjs) is fail-closed once wired: an unregistered chain or token is rejected at offer time. Inclusion is an explicit reviewer decision, not automatic — the registry records provenance on every entry.
Journal-first recovery: every state transition and release step is appended
to a JSONL journal before (or immediately after, with a recovery marker) the
irreversible rail action, so reconcile() can re-run release after a crash
without double-paying (src/escrow/escrow-service.mjs).
Fees and terms are between the two parties, not the escrow. The rail's
tip fee sits outside the signed agreement; the parties choose who absorbs it
(--fee-allocation seller|buyer|split, or an explicit --payout amount) —
the escrow never derives a default (src/escrow/terms.mjs).
Verifiable Delivery Attestation (VDA-1)
src/vda/ implements VDA-1, a delivery-attestation format proposed as
IAASO-3901 at the IAASO governance registry. The proposal is currently
in ballot — it has not been adopted. It is not exported from
src/index.mjs, and the package.json exports map (., ./keychain, ./jcs,
./tier, ./envelope, ./e2e, ./carrier-client, ./package.json) blocks deep imports
of src/vda/* from the npm package — use it from a checkout of this repository, or wait
for a ./vda export (proposed, not shipped).
What it provides:
- Claim normalization to a closed set of per-kind field allowlists
(
transfer,data-delivery,message-delivery,media-availability,fiat-settlement), dropping unknown fields before hashing (src/vda/core.mjs). - Content-addressed attestation ids and a signed (Ed25519 over JCS) attestation envelope, verifiable fully offline.
- Composite receipts and quorum over multiple attestations (worst-verdict wins).
- An append-only verifier key directory (
src/vda/directory.mjs) —directoryId = sha256(jcs(records)), so two holders can compare directories by hash; rotation is append, never rewrite. - Instruments (
src/vda/instruments.mjs):transferclaims verify against the rail's own chain receipt (src/escrow/verify.mjs'sTransferVerifier);data-deliveryuses a recipient-signed delivery ack;fiat-settlementwraps an issuer artifact and can reach at bestissuer-attested. - Shims (
src/vda/shims.mjs) that translate x402 settlements and emit A2A artifacts / an MCP tool descriptor — translation only, never an upgrade in trust.
Closed verdict set: verified, failed, pending, instrument-blind,
issuer-attested, unsupported. A rail structurally unable to answer a
claim returns instrument-blind, never a silent negative
(src/vda/core.mjs).
What a tier grant is — and is not
A tier grant (src/identity/tier.mjs) raises a sender's carrier budget for
rate and body size — see "Tier grants" above for the ladder. It is a
throughput entitlement, not a vetting, KYC, or compliance credential.
- Hard ceilings any grant can reach, regardless of what it claims:
ABSOLUTE_MAX_RATE= 6,000 envelopes/min,ABSOLUTE_MAX_BODY= 2 MiB (src/identity/tier.mjs). - Issuing a grant does not identity-verify, background-check, or vet the holder. "Qualification" — whatever process decides who receives one — is entirely off-wire; this module only makes an already-decided grant unforgeable and portable.
- A grant cannot be revoked before its
expiresdate. The only lever a carrier operator has is to stop trusting the issuer's key outright (src/net/carrier-server.mjs). - Carrier trust of an issuer is opt-in per carrier
(
pillar-carrier --tier-issuers <pubkey-hex>); a carrier that trusts no issuer treats every sender as community-tier no matter what grant is presented.
Limits and hazards for integrators
- Every number is in
HOSTING-LIMITS.md. It covers the long-poll cap and cadence, body sizes, rate windows, what 429/503/413 mean and what to do about each, push/wake, and proxy settings for hosts. Each number cites its source line. - One home, one live consumer. A daemon-owned home directory and an
interactive
pillar inboxrun against the same home can race on envelope id — run one consumer per home. - Receipts are app-layer, not protocol-layer. The
ackenvelope IS the receipt (seeINTERACTION-CONVENTION.md, "carrier limitation #5"). The numbered list that citation implies was never published, and no items #1-#4 exist in this tree.HOSTING-LIMITS.mdis now the published list of what a carrier enforces. It does not use that numbering. - Carrier retention is time-bounded. Envelopes are retained until TTL,
default 14 days, then vacuumed (
src/net/carrier-server.mjs). - No metering. The carrier's rate limiter is an in-memory sliding window that resets on every restart; nothing records cumulative use across time. A tier grant changes the window's ceiling, not whether usage is tracked.
- Grants are not revocable before expiry — see "What a tier grant is — and is not" above; plan grant lifetimes accordingly.
Source, provenance and releases
Apache-2.0 (LICENSE). This repository is the recovered source for
@uuaid/pillar — see RECOVERY.md for how it was reconstituted and kept in
sync with the published package. The 0.2.0-alpha.13 tarball this working
tree is synced to: sha256
e80d1e339d7cce53dcd01a3b637aab96adfc7bdb22ccbf08cfa9841f4fda3f5f,
105,015 bytes, 38 files (npm pack @uuaid/[email protected]). Releases
(npm publish) are an owner action; nothing in this repository publishes on
its own.
License
Apache-2.0.
