@uuaid/pillar 0.2.0-alpha.16 → 0.2.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ # Pillar limits: polling, push/wake, and hosting
2
+
3
+ This page lists every limit a Pillar carrier enforces, what the shipped
4
+ clients do about each one, and how push/wake works. Read it before you write
5
+ a poll loop or host a carrier, seed, or push relay.
6
+
7
+ **Sources.** Each number is cited once, as `path:line`. Paths under `src/` and
8
+ `bin/` are in this package (`@uuaid/pillar` 0.2.0-alpha.18). Paths under
9
+ `apps/`, `packages/` and `infra/` are in the UUAID monorepo at commit
10
+ `7e51fa2`, which is not public. `infra/pillar-blob/server.mjs` is cited at
11
+ monorepo commit `229c217`, the agent-first broker with RedTeam's conditions
12
+ applied, which is not deployed yet; a citation marked `ff46fd8` is the broker
13
+ as deployed. In §1 and §5,
14
+ a bare `:N` means `src/net/carrier-server.mjs:N`. Anything marked **live** was
15
+ measured on 2026-09-27.
16
+
17
+ **Enforced** means the carrier refuses you. **Default** means a client picked
18
+ the value and you can change it.
19
+
20
+ ## 1. Enforced by the carrier
21
+
22
+ A refused send answers `{accepted: false, reason}`. A refused inbox read
23
+ answers `{error}`. From 0.2.0-alpha.18 each refusal also carries `fix`, one
24
+ line saying what to do (§6); `reason`, `error` and the status codes are
25
+ unchanged. Each carrier counts on its own, and `deliver()` sends every
26
+ envelope to every carrier, so each carrier counts all of your sends.
27
+
28
+ ### Reading: `GET /v1/inbox/<uuaid>?since=<seq>&wait=<seconds>`
29
+
30
+ | Limit | Value | Source | When you hit it | What to do |
31
+ |---|---|---|---|---|
32
+ | Long-poll `wait` | 30 s. A larger value is cut to 30 without an error | :63 | 200 with `envelopes: []` after 30 s | Ask for 25 (§3) |
33
+ | Re-check while waiting | every 400 ms | :426 | n/a | Mail reaches a waiting poll about 0.4 s after it is stored, whatever `wait` is |
34
+ | Rows per reply | 200, oldest first | :418 | 200 with 200 rows | Poll again at once, `since` = highest `seq` |
35
+ | Open long-polls (`wait` > 0) | 48 for community; 64 in all, so 16 are kept for priority grants | :82, :74 | 503 `busy` | Wait at least 2 s, with jitter, and retry, or use `wait=0` for a while |
36
+ | `x-pillar-ts` clock skew | ±5 min | :66 | 401 `clock-skew` | Fix your clock (NTP). Retrying does not help |
37
+ | `x-pillar-delegation` size | 8,192 bytes | :67 | 401 `delegation-too-large` | Send a smaller delegation document |
38
+
39
+ **Authentication** (:330-361). Send `x-pillar-pubkey`, `x-pillar-ts` (Unix ms)
40
+ and `x-pillar-sig`, which is Ed25519 over
41
+ `GET /v1/inbox/<uuaid>?since=<seq>` + `"\n"` + ts. `wait` is not signed. The
42
+ uuaid must be derived from the key. To read another identity's inbox, also
43
+ send `x-pillar-delegation`: a delegation document signed by that identity that
44
+ names your key and covers `inbox` or `*` (:376-392). Every auth failure is a
45
+ 401 with the reason in `error`. Reads have no rate limit. Only the open
46
+ long-polls above are capped, and `wait=0` reads are not capped at all.
47
+
48
+ ### Sending: `POST /v1/envelopes`
49
+
50
+ The carrier runs these checks in order (:267-313). Each budget is spent when
51
+ its check passes. So a send refused later for being stale or for a full store
52
+ has still used budget, and so has a duplicate.
53
+
54
+ | Check | Value | Source | When you hit it | What to do |
55
+ |---|---|---|---|---|
56
+ | Raw body read | 2 MiB + 8 KiB | :65 | Connection dropped, no JSON body | Stay under your tier's body limit |
57
+ | Envelope signature and shape | n/a | :268-274 | 400 with the reason (403 `delegation-invalid:*`) | Fix the envelope |
58
+ | Refused sends per client IP | 60 per window | :73 | 429 `rate-limited`, which hides the real reason | Stop and fix the sender |
59
+ | Body size | 512 KiB community; a tier grant can raise it (table below) | :64 | 413 `body-too-large` + `maxBodyBytes`, `tier` | Send the payload as a blob (§3) |
60
+ | Sends per sender uuaid | 120 per window for community | :71 | 429 `rate-limited` | Back off (§3) |
61
+ | Accepted sends per client IP | 300 per window; priority grants use their own bucket of 600 | :72, :83 | 429 `rate-limited` | Everyone behind your NAT shares this budget |
62
+ | Envelope `createdAt` | within ±24 h of carrier time | :69 | 400 `stale-envelope` | Seal a new envelope. Never retry an old one |
63
+ | Store size | 256 MiB. Community sends are refused above 85% (217.6 MiB) | :75, :81 | 503 `carrier-full` | Your other carriers still take it. Don't retry this one soon |
64
+
65
+ Sending the same envelope id again is safe. It returns 202 with
66
+ `duplicate: true` (:322-324).
67
+
68
+ **Windows, memory, retention.** Every rate window is a sliding 60 s (:70),
69
+ held in process memory (:114), so a restart resets every budget; past 10,000
70
+ keys the oldest is dropped (:76). Envelopes are kept 14 days by default (:86;
71
+ `--ttl`, bin/carrier.mjs:32, bin/pillar-seed.mjs:66) and expired rows are
72
+ deleted hourly (bin/carrier.mjs:46-49, bin/pillar-seed.mjs:171-174).
73
+
74
+ **Tier grants** (src/identity/tier.mjs:52-56). A signed grant in
75
+ `x-pillar-tier-grant` raises its holder's send budgets:
76
+
77
+ | Tier (alias) | Sends per window | Max body |
78
+ |---|---|---|
79
+ | community (Doric) | 120 | 512 KiB |
80
+ | supporter (Ionic) | 300 | 1 MiB |
81
+ | org (Corinthian) | 600 | 2 MiB |
82
+ | enterprise | 1,200 | 2 MiB |
83
+ | foundation | 3,000 | 2 MiB |
84
+
85
+ No grant can go above 6,000 sends per window or 2 MiB
86
+ (src/identity/tier.mjs:67-68). Only `pillar-carrier --tier-issuers <hex>`
87
+ honors grants (bin/carrier.mjs:35-38). `pillar-seed` passes no issuers
88
+ (bin/pillar-seed.mjs:117), so on a seed every sender gets community budgets
89
+ and the priority reserves are never used. `@uuaid/pillar-client` never sends a
90
+ grant.
91
+
92
+ ## 2. What the shipped clients do (defaults)
93
+
94
+ | Client | `wait` | Request timeout | After an error | Carriers |
95
+ |---|---|---|---|---|
96
+ | `pillar recv`, `Pillar.startCarrierPolling()` | 25 s (src/pillar.mjs:431) | 40 s (src/net/carrier-client.mjs:143) | sleep 2 s (src/net/carrier-client.mjs:187) | all in parallel, one cursor each (src/net/carrier-client.mjs:175-180) |
97
+ | `pillar inbox` | 0, reading pages until one is empty (src/pillar.mjs:464-468) | 40 s | exits | one at a time |
98
+ | `pillar-responder` | 20 s (bin/pillar-responder.mjs:299) | 40 s | sleep 2 s | all in parallel |
99
+ | `@uuaid/pillar-client` `poll()` / `subscribe()` | 25 s (packages/pillar-client/src/carrier.ts:149) | `wait` + 15 s (packages/pillar-client/src/carrier.ts:158) | sleep 2 s (packages/pillar-client/src/carrier.ts:287) | the first in list order that answers (packages/pillar-client/src/carrier.ts:160-167) |
100
+ | Pillar mobile app, on screen | 25 s (apps/mobile/src/services/pillar.ts:345) | `wait` + 15 s | sleep 2 s | as `pillar-client`, plus one loop for each extra carrier a linked desktop needs (apps/mobile/src/services/pillar.ts:208-213) |
101
+ | Push relay | 25 s, 30 at most (apps/pillar-push/src/config.ts:48) | `wait` + 20 s (apps/pillar-push/src/poller.ts:130) | sleep 2 s (apps/pillar-push/src/poller.ts:198) | all; one loop per uuaid per carrier (apps/pillar-push/src/poller.ts:95-103) |
102
+
103
+ `deliver()` in both libraries posts to every carrier in parallel, with a 10 s
104
+ timeout each (src/net/carrier-client.mjs:81,
105
+ packages/pillar-client/src/carrier.ts:86). It succeeds if any carrier accepts.
106
+ `@uuaid/pillar` checks the body size before sending
107
+ (src/net/carrier-client.mjs:85-92). `@uuaid/pillar-client` does not, so you get
108
+ the carrier's 413 instead.
109
+
110
+ Neither library limits `wait`; the carrier does. **Live:** from
111
+ `@uuaid/pillar-client`, `CarrierClient.poll({ waitSeconds: 60 })` sent
112
+ `wait=60` to `https://pillar-sfo.uuaid.org` and got 200 with no envelopes
113
+ after 30.18 s.
114
+
115
+ **Hazard in `@uuaid/pillar-client`.** Its `carriers` list is a failover list
116
+ that shares one `since` cursor, but each carrier numbers `seq` on its own. If
117
+ the first carrier fails and `poll()` falls back to the next one, it asks that
118
+ carrier for seqs above the first carrier's cursor, and it can skip mail. Give
119
+ each carrier its own `CarrierClient` and cursor, as the mobile app does for
120
+ extra carriers.
121
+
122
+ ## 3. Recommendations (each follows from §1)
123
+
124
+ - **Use `wait=25`.** The carrier stops at 30 s and can add one 400 ms tick. The
125
+ live proxy read timeout is 40 s (§5). 25 leaves 15 s of margin, and an idle
126
+ client makes about 2.4 requests per minute per carrier. Set your request
127
+ timeout to the effective wait (at most 30 s) plus 10 to 15 s.
128
+ - **Don't poll more often to get mail sooner.** A waiting poll sees new mail in
129
+ about 0.4 s. If you want less idle traffic, use `wait=0` on a timer instead.
130
+ The carrier does not limit that, but your delay then equals the timer.
131
+ - **Keep one long-poll per carrier per identity.** All users of a carrier share
132
+ its 48 community slots. A second poll for the same inbox only takes a slot
133
+ from someone else.
134
+ - **Plan on 120 sends per minute per identity in total.** Every carrier sees
135
+ every envelope. Acks count too: a responder that acks and replies spends 2
136
+ sends per request, so it can answer about 60 requests per minute. With a
137
+ poll open you see a request in about 0.4 s, so the 30 s ack window
138
+ (INTERACTION-CONVENTION.md:54) is spent on your own work, not on polling.
139
+ - **On 429:** budgets refill over a sliding 60 s. Back off, for example from
140
+ 5 s doubling to 60 s. Resending the same envelope is safe but spends budget.
141
+ - **On 503 `busy`:** wait at least 2 s, with jitter (shipped clients use a
142
+ fixed 2 s). **On 503 `carrier-full`:** see §5; your other carriers still
143
+ take the envelope.
144
+ - **On 401 `clock-skew`:** fix your clock. Keep it within 5 min, and send each
145
+ envelope within 24 h of sealing it.
146
+ - **Over 512 KiB, send a blob** (`pillar-blob`). One blob object can be up to
147
+ 50 MiB (src/blob/blob.mjs:46). **Any agent that holds its key can send and
148
+ receive one.** Exactly one UUAID proves itself with its key:
149
+ `uuaid:foundation:agent:` followed by `sha256(publicKey)[:16]` as a UUID,
150
+ rebuilt in full and compared as a whole string (src/blob/blob.mjs:177-193).
151
+ Every identity `pillar init` mints is one, and it is the rule every envelope
152
+ is accepted on. A device, a service, another namespace, or the same local id
153
+ under any other prefix is not self-certified and needs a registry badge
154
+ naming its key. A self-certified identity signs broker requests with keyId
155
+ `self-certified:ed25519`. When a fresh registry badge names this exact key,
156
+ the client presents the badge's key instead, so badge holders keep badge
157
+ limits; a badge that names another key is not used, and `--key-id` is
158
+ optional (src/blob/http.mjs:133-159). A recipient accepts a blob-ref whose
159
+ sender is exactly the self-certified UUAID of the key that signed the ref,
160
+ without asking the registry (src/blob/blob.mjs:488-489). Such a sender is a
161
+ proven key holder with an **unverified identity**: `pillar-blob get` shows
162
+ its UUAID only, never a name or badge mark (bin/pillar-blob.mjs:126). Any
163
+ other sender needs a badge naming its signing key; without one, the
164
+ recipient refuses with `sender-key-not-active` before anything is redeemed.
165
+ On the self-certified path the broker adds, on top of its per-uuaid quota of
166
+ 250 MiB and 20 objects a day (infra/pillar-blob/server.mjs:79-80):
167
+ - per client network, over a rolling 24 h: 250 MiB and 40 new objects
168
+ across all self-certified identities from it
169
+ (infra/pillar-blob/server.mjs:113-114). IPv4 counts by address and IPv6
170
+ by /64, never finer (infra/pillar-blob/server.mjs:118, :121);
171
+ - self-certified bytes at most 50% of the broker store, counting only
172
+ uploaded, hash-checked objects, checked at reserve and again at finalize
173
+ (infra/pillar-blob/server.mjs:115). A reservation that is never finalized
174
+ expires after 1 h whatever its expiry (infra/pillar-blob/server.mjs:126);
175
+ - expiry at most 72 h (infra/pillar-blob/server.mjs:117), against 7 days
176
+ with a badge (infra/pillar-blob/server.mjs:78). The client default is
177
+ 24 h (src/blob/blob.mjs:48);
178
+ - each self-certified blob can be redeemed at most 20 times
179
+ (infra/pillar-blob/server.mjs:122).
180
+
181
+ On every path, `POST /pillar/blobs/challenge` is limited to 120 a minute
182
+ per client network (infra/pillar-blob/server.mjs:129). Each limit has a
183
+ `PILLAR_BLOB_*` variable, and `PILLAR_BLOB_SELF_CERTIFIED=0` turns the
184
+ self-certified path off (infra/pillar-blob/server.mjs:112). That path makes
185
+ no registry lookup, so a registry outage does not block it.
186
+ **Deployment:** until pillar-sfo runs the agent-first broker, the live one
187
+ still refuses an identity without a badge with 401 `inactive or mismatched
188
+ registry key` (infra/pillar-blob/server.mjs:332 at `ff46fd8`). **Live**
189
+ (2026-09-27, pillar-sfo broker, before that release): 32 KiB, 1 MiB and
190
+ 10 MiB round trips between two fleet identities decrypted byte-identical;
191
+ a fresh unlisted identity was refused as both sender and recipient.
192
+
193
+ ## 4. Push and wake (`apps/pillar-push`)
194
+
195
+ A push only wakes the phone. It never carries message content and never
196
+ delivers mail. The phone still polls the carriers and decrypts the mail
197
+ itself.
198
+
199
+ **How the relay learns about mail.** For each registered uuaid with a valid
200
+ poll token (a delegation from the user granting only `inbox`), the relay holds
201
+ a long-poll on every carrier, signed with its own key and carrying the token
202
+ as `x-pillar-delegation` (apps/pillar-push/src/poller.ts:114-131). The first
203
+ poll of a new uuaid on a carrier records the current position and pushes
204
+ nothing for older mail (apps/pillar-push/src/poller.ts:155-161).
205
+ Registrations are re-read every 5 s and at once after each registration
206
+ (apps/pillar-push/src/index.ts:87, apps/pillar-push/src/index.ts:103). An
207
+ envelope seen on several carriers counts once; the relay remembers the last
208
+ 1,000 ids per uuaid (apps/pillar-push/src/poller.ts:189).
209
+
210
+ **Coalescing.** The relay sends at most one push per uuaid per 30 s
211
+ (apps/pillar-push/src/config.ts:44). The first envelope after a quiet window
212
+ is pushed at once with `envelopes: "1"`. Any more in that window become one
213
+ push at the window's end, carrying their count
214
+ (apps/pillar-push/src/dispatcher.ts:61-86).
215
+
216
+ **Delay, as far as the code sets it:** about 0.4 s for the relay to see a
217
+ stored envelope, then 0 s for the first envelope or up to 30 s for later ones
218
+ in the window, then Google's or Apple's delivery time (not controlled here),
219
+ then the app's own poll.
220
+
221
+ | Platform | What the push contains | Who sees what |
222
+ |---|---|---|
223
+ | Android (FCM) | data only: `{kind: "pillar-wake", uuaid, envelopes}`, priority HIGH (apps/pillar-push/src/providers/fcm.ts:89-94) | Google sees the recipient's uuaid and the count |
224
+ | iOS (APNs) | `{"aps": {"content-available": 1}}`, background, priority 5 (apps/pillar-push/src/providers/apns.ts:85, apps/pillar-push/src/providers/apns.ts:101-102) | Apple sees the device token and the app's topic. iOS decides when to deliver it |
225
+
226
+ **On the phone.** On Android, while the app is not on screen,
227
+ `PillarMessagingService` shows one content-free "New message" notification,
228
+ each replacing the last
229
+ (apps/mobile/android/app/src/main/java/org/uuaid/pillar/PillarMessagingService.java:36);
230
+ none if notification permission is off on Android 13+
231
+ (PillarMessagingService.java:44-48), and none while the app is on screen
232
+ (PillarMessagingService.java:43), where its own poll loop shows the mail. A tap opens the app, which polls the carriers (`wait` 25) and decrypts.
233
+ The app has no background fetch: while it is closed, the push is its only
234
+ signal.
235
+
236
+ **Registration** (`POST /v1/push/register`) is Ed25519-signed; `ts` outside
237
+ ±5 min gets 401 `clock-skew` (apps/pillar-push/src/config.ts:49). A uuaid not
238
+ proven by the signing key or an `authExtra` entry gets 401
239
+ `uuaid-not-proven`. At most 50 uuaids per request
240
+ (apps/pillar-push/src/routes/register.ts:42). One invalid poll token rejects
241
+ the whole request with 400 `polltoken-invalid:<reason>`
242
+ (apps/pillar-push/src/routes/register.ts:128-132). A registration without a
243
+ token is stored but never polled. The route has no rate limit.
244
+
245
+ **Lifetimes.**
246
+ - Poll token: the app mints a 30-day token
247
+ (apps/mobile/src/services/push-relay.ts:148) for both the persona and the
248
+ device (apps/mobile/src/services/pillar.ts:2543) at every cold launch
249
+ (apps/mobile/src/services/pillar.ts:379). The relay stops polling a uuaid
250
+ once its token's `notAfter` has passed (apps/pillar-push/src/db.ts:111). So
251
+ a phone whose app is not opened for 30 days stops getting pushes.
252
+ - The persona-to-device delegation lasts 90 days, and the app mints a new one
253
+ at launch if the old one is no longer valid
254
+ (apps/mobile/src/services/pillar.ts:269).
255
+ - A dead device token deletes its registration
256
+ (apps/pillar-push/src/dispatcher.ts:102-105): FCM 404, `UNREGISTERED` or
257
+ `NOT_FOUND` (apps/pillar-push/src/providers/fcm.ts:116); APNs 410, or 400
258
+ `BadDeviceToken`/`Unregistered` (apps/pillar-push/src/providers/apns.ts:119).
259
+
260
+ **Known gap.** A poll loop that is already running keeps the token it started
261
+ with (apps/pillar-push/src/poller.ts:99-103). A new registration stores a
262
+ fresh token, but the loop only uses it after the relay restarts. If the relay
263
+ runs past the first token's expiry, that loop gets a 401 every 2 s and the
264
+ pushes stop, even though a valid token is stored.
265
+
266
+ **Relay capacity.** Each pollable uuaid takes one community long-poll slot on
267
+ every carrier, out of 48 per carrier (§1). The app registers 2 uuaids per
268
+ phone, so one relay fills a carrier's community slots at 24 phones. After that,
269
+ new long-polls on that carrier, the relay's and everyone else's, get 503
270
+ `busy` while 48 are open.
271
+
272
+ **Live deployment** (`/push/v1/health`, 2026-09-27): `dryRun: false` and
273
+ `live: ["android"]`. iOS pushes are logged, not sent (`PUSH_LIVE_PLATFORMS`,
274
+ apps/pillar-push/src/config.ts:98-108). `fcmKey` expires 2026-12-24, with
275
+ `daysLeft: 88` (apps/pillar-push/src/routes/health.ts:27-29). At startup the
276
+ relay logs a warning when fewer than 14 days are left
277
+ (apps/pillar-push/src/index.ts:80). The relay watches 6 carriers and holds 4
278
+ registrations and 4 poll tokens.
279
+
280
+ ## 5. Hosting a carrier, seed or relay
281
+
282
+ `pillar-carrier` and `pillar-seed` listen on 127.0.0.1:8799 by default
283
+ (bin/carrier.mjs:29-30, bin/pillar-seed.mjs:63-64). The relay listens on
284
+ 127.0.0.1:8801 (apps/pillar-push/src/config.ts:38-39). Put a TLS proxy in
285
+ front of each one.
286
+
287
+ | Proxy rule | Why | pillar-sfo (live) and repo template |
288
+ |---|---|---|
289
+ | Carrier read timeout > 30 s + margin | The carrier can hold a request for 30 s plus one 400 ms tick | `/v1/` → 127.0.0.1:8799, 40 s; template: infra/pillar-carrier-fleet/provision-carrier.sh:102 |
290
+ | Replace `X-Forwarded-For` with the peer's address | The carrier takes the first XFF entry as the client IP (:208-212). If the proxy appends to XFF, a client can pick its own rate bucket. If the proxy sends no XFF, all clients share the proxy's one bucket | template: `$remote_addr` (infra/pillar-carrier-fleet/provision-carrier.sh:107) |
291
+ | Body size ≥ largest honored body + 8 KiB | 512 KiB on a seed; 2 MiB + 8 KiB with `--tier-issuers`. Below that, clients get the proxy's own 413 page, not JSON | the template sets no size on `/v1/`, so the proxy's default applies. Set it explicitly |
292
+ | CORS: allow `x-pillar-pubkey`, `x-pillar-ts`, `x-pillar-sig` | Browser clients send them. Also allow `x-pillar-delegation` and `x-pillar-tier-grant` if browsers poll by delegation or send grants | the template allows neither of those two (infra/pillar-carrier-nyc/nginx-setup.sh:25) |
293
+ | Relay: a short timeout is fine | No relay route long-polls | `/push/` → 127.0.0.1:8801/, 30 s |
294
+ | Blob objects: at least 50 MiB + headers | The largest blob object is 50 MiB | `/blob/objects/` 52m and 300 s; `/blob/` 1m and 40 s (infra/pillar-carrier-nyc/install-blob.sh:59-62, infra/pillar-carrier-nyc/install-blob.sh:71-74) |
295
+
296
+ **Under load:**
297
+ - **`carrier-full` does not clear by itself.** `vacuum()` deletes expired rows
298
+ (:239-243) but never runs SQLite `VACUUM`. The size check reads `page_count`
299
+ (:308-311), which still counts freed pages. In a local test with the same
300
+ schema, deleting all 300 rows of a 30,904,320-byte store left
301
+ `page_count × page_size` at 30,904,320 bytes, with 7,540 pages free. So once
302
+ the file passes 217.6 MiB, community sends get 503 until you stop the
303
+ carrier, run `sqlite3 <db> 'VACUUM;'`, and restart it. On a `pillar-seed`
304
+ every sender is community, so that means everyone. Watch the file size.
305
+ - Sends get 429 and 503 as in §1; new long-polls get 503 `busy` once 48
306
+ community (64 in all) are open.
307
+ - Nothing limits `wait=0` reads, `/v1/health` or `/v1/seed-info`. Rate-limit
308
+ them in your proxy if you need to.
309
+ - A restart resets all rate budgets and cuts every open long-poll (the shipped
310
+ clients retry after 2 s). Stored envelopes stay in SQLite. Leave disk for a
311
+ 256 MiB store plus its WAL file.
312
+
313
+ **Relay host.** Back up `PUSH_RELAY_SEED_PATH`. Every poll token names the
314
+ relay's key, so losing that file invalidates all of them
315
+ (apps/pillar-push/src/config.ts:19-22). Watch `fcmKey.daysLeft` on
316
+ `/v1/health`.
317
+
318
+ ## 6. Refusal codes
319
+
320
+ Every refusal names a stable code and one line saying what to do. The
321
+ carrier's code is the `reason` of a refused send or the `error` of a refused
322
+ inbox read; the broker's is its `code`. `pillar` and `pillar-blob` print a
323
+ refusal as one line, `<code>: <fix>`: the server's `fix` when it sends one,
324
+ printed as received (only line breaks and control characters become spaces),
325
+ else the client's map (src/net/refusals.mjs:24-73, exported as
326
+ `@uuaid/pillar/refusals`). A carrier older than 0.2.0-alpha.18 sends no
327
+ `fix`; the client fills it in. On a thrown error, `err.code` and `err.fix`
328
+ carry the same pair. Every fix, the client's and both servers', is a fixed
329
+ string from a closed set: none is built from request data, a remote reason
330
+ or an exception message. A parameterised reason such as
331
+ `delegation-invalid:<why>` gets its family's fix, without `<why>`.
332
+
333
+ ### Carrier
334
+
335
+ | Code | Status | Fix |
336
+ |---|---|---|
337
+ | `rate-limited` | 429 | Back off: budgets refill over a sliding 60 s, so retry after 5 s, doubling to 60 s |
338
+ | `body-too-large` | 413 | Send the payload as a blob (`pillar-blob send`); `maxBodyBytes` is your tier's inline limit |
339
+ | `stale-envelope` | 400 | Seal a new envelope and check your clock; `createdAt` must be within 24 h of carrier time |
340
+ | `carrier-full` | 503 | Use your other carriers and do not retry this one soon; its store is full |
341
+ | `bad-recipient` | 400 | Address the envelope to a full UUAID (`uuaid:<namespace>:<type>:<id>`) |
342
+ | `bad-json` | 400 | POST the envelope as one JSON object |
343
+ | `keyId-sender-mismatch` | 400 | Sign with `transportSignature.keyId` equal to the sender UUAID, or send under a delegation |
344
+ | `publicKey-sender-mismatch` | 400 | Send from the UUAID your signing key derives, or attach the principal's delegation |
345
+ | `bad-signature` | 400, 401 | Sign again with this identity's own key over the exact bytes; the signature does not verify |
346
+ | `not-object`, `bad-version <v>`, `missing-or-bad-alg`, `alg-not-active:<alg>`, `bad-publicKey`, `bad-signature-encoding`, `missing-enc`, `enc-alg-not-supported:<alg>`, `enc-alg-not-active:<alg>`, `bad-epk`, `bad-iv`, `bad-ct`, `bad-canonical`, `verify-threw` | 400 (`verify-threw` also 401) | Re-seal the envelope with `seal()` from `@uuaid/pillar` or `@uuaid/pillar-client`; this one does not verify |
347
+ | `delegation-invalid:<why>` | 403 (send), 401 (inbox) | Mint a fresh delegation from the principal, then resend |
348
+ | `missing-auth-headers` | 401 | Send `x-pillar-pubkey`, `x-pillar-ts` and `x-pillar-sig` |
349
+ | `bad-pubkey` | 401 | Send `x-pillar-pubkey` as the raw Ed25519 key in 64 hex characters |
350
+ | `bad-sig-encoding` | 401 | Send `x-pillar-sig` as 128 hex characters |
351
+ | `clock-skew` | 401 | Fix your clock (NTP) to within 5 min of the carrier; retrying does not help |
352
+ | `uuaid-pubkey-mismatch` | 401 | Read the inbox of the UUAID your key derives, or send that UUAID's delegation in `x-pillar-delegation` |
353
+ | `delegation-unparseable` | 401 | Send `x-pillar-delegation` as the JSON delegation document |
354
+ | `delegation-too-large` | 401 | Send a delegation document under 8,192 bytes |
355
+ | `delegation-principal-mismatch` | 401 | Use a delegation whose principal is the inbox UUAID |
356
+ | `delegation-delegate-key-mismatch` | 401 | Sign with the key the delegation names as delegate |
357
+ | `delegation-inbox-denied` | 401 | Ask the principal for a delegation that covers `inbox` (or `*`) |
358
+ | `busy` | 503 | Retry after at least 2 s with jitter, or poll with `wait=0` for a while |
359
+ | `not-found` | 404 | Use `POST /v1/envelopes` or `GET /v1/inbox/<uuaid>` |
360
+ | `not-a-seed` | 404 | Do not list this carrier as a seed; it runs without a seed identity |
361
+ | `internal-error` | 500 | Retry later; if it repeats, tell the carrier operator |
362
+
363
+ ### Client (no server gave a code)
364
+
365
+ | Code | Where | Fix |
366
+ |---|---|---|
367
+ | `carrier-unreachable` | every carrier failed without answering | Check the carrier URLs and your network, then retry |
368
+ | `carrier-bad-response` | a carrier or its proxy answered without a reason | Retry, or use another carrier |
369
+ | `over-tier-limit`, `blob-too-large` | send preflight, before any network call (src/net/carrier-client.mjs:90) | Send the payload as a blob (`pillar-blob send`, or `Pillar.sendBlob`) |
370
+ | `blob-key-unproven` | `pillar-blob`, before signing a broker request | Use a `uuaid:foundation:agent` UUAID derived from this key (`pillar init`), or get a registry badge that names this key |
371
+ | `sender-key-not-active` | `pillar-blob get`, before redeeming | Ask the sender to send from a `uuaid:foundation:agent` UUAID derived from its own key, or to get a registry badge naming its key |
372
+
373
+ ### Broker (`pillar-blob`)
374
+
375
+ The agent-first broker answers every refusal with
376
+ `{error, code, fix, ...numbers}`; its 46 codes and fixes are one closed table
377
+ of plain strings (infra/pillar-blob/server.mjs:141-194). `error` is the sentence
378
+ it has always sent, and a number a fix names is a field of the same
379
+ response; quota and rate refusals add `retry_after_seconds`. The client puts
380
+ `code`, `fix` and the body on the thrown error (src/blob/http.mjs:110-116).
381
+ The deployed broker (`ff46fd8`) sends no `code`, so the client keeps its own
382
+ `blob-broker-not-found`, `-expired`, `-replay` or `-rejected` (by status),
383
+ which stays available as `err.legacyCode` either way.
384
+
385
+ | Code | Status | Fix |
386
+ |---|---|---|
387
+ | `request_body_invalid` | 400 | Send a JSON object body with content-type application/json. |
388
+ | `request_body_too_large` | 413 | Keep broker JSON requests under 128 KiB; the file itself goes to upload.url. |
389
+ | `challenge_request_invalid` | 400 | POST {"action":"reserve"\|"finalize"\|"redeem","agent_uuaid":"uuaid:&lt;namespace>:&lt;type>:&lt;id>"}. |
390
+ | `object_id_required` | 400 | Include object_id (64 hex, from your reserve response or blob-ref) for finalize and redeem. |
391
+ | `object_id_not_allowed` | 400 | Omit object_id for reserve; the broker assigns it in the challenge. |
392
+ | `signed_request_malformed` | 400 | Send {payload:{v:1,audience:"uuaid:pillar-blob:v1",challenge,request}, payloadHash:"0x"+sha256(jcs(payload)), signatures:[one ed25519 signature]}. |
393
+ | `reserve_request_malformed` | 400 | Give reserve a recipient_uuaid, ciphertext_sha256 (64 hex), ciphertext_bytes (integer > 0) and expires_at (ISO 8601). |
394
+ | `action_endpoint_mismatch` | 400 | POST reserve, finalize and redeem requests to /pillar/blobs/reserve, /finalize and /redeem respectively. |
395
+ | `challenge_unknown` | 401 | POST /pillar/blobs/challenge and sign the challenge it returns. |
396
+ | `challenge_expired` | 401 | POST /pillar/blobs/challenge for a fresh challenge and use it before its expires_at. |
397
+ | `challenge_consumed` | 409 | Challenges are single-use: POST /pillar/blobs/challenge for a fresh one and sign again. |
398
+ | `challenge_mismatch` | 401 | Sign the challenge exactly as returned, with request.action and request.agent_uuaid equal to its action and agent_uuaid. |
399
+ | `challenge_rate_limited` | 429 | Wait retry_after_seconds; this network asked for more than max_per_minute challenges in a minute. |
400
+ | `registry_key_inactive` | 401 | Sign with keyId "self-certified:ed25519" as uuaid:foundation:agent:&lt;local id derived from your key>, or with the keyId your registry badge names. |
401
+ | `self_certified_uuaid_mismatch` | 401 | With keyId "self-certified:ed25519", agent_uuaid must be exactly uuaid:foundation:agent:&lt;first 16 bytes of sha256(publicKey), hex 8-4-4-4-12>; any other UUAID needs a registry badge. |
402
+ | `self_certified_disabled` | 401 | Sign with the keyId your registry badge names; this broker accepts registry badges only. |
403
+ | `registry_unavailable` | 502, 503 | Retry in a minute; keyId "self-certified:ed25519" with a key-derived uuaid:foundation:agent UUAID does not need the registry. |
404
+ | `signature_invalid` | 401 | Sign the UTF-8 bytes of jcs(payload) with the ed25519 key whose publicKey you send. |
405
+ | `object_too_large` | 413 | Keep ciphertext_bytes at or under max_bytes; split larger files. |
406
+ | `expiry_out_of_range` | 400, 410 | Set expires_at between min_ttl_ms and max_ttl_ms from now. |
407
+ | `self_certified_ttl_exceeded` | 400 | Set expires_at at most max_ttl_ms from now; a registry badge allows longer. |
408
+ | `daily_quota_exceeded` | 429 | Wait retry_after_seconds, or send fewer or smaller blobs; this identity's 24-hour quota is used up. |
409
+ | `self_certified_ip_quota_exceeded` | 429 | Wait retry_after_seconds; self-certified senders from this network have used their 24-hour allowance. |
410
+ | `self_certified_pool_full` | 507 | Reserve again later; the self-certified share of the store (uploaded bytes) is full. |
411
+ | `store_full` | 507 | Retry later or with a shorter expires_at; the broker's store is full. |
412
+ | `reservation_not_found` | 404 | Finalize only an object_id you reserved, as the same agent_uuaid, before it expires. |
413
+ | `reservation_not_finalizable` | 409 | This object is already finalized or quarantined; reserve a new object to send again. |
414
+ | `reservation_expired` | 410 | Reserve again, then upload and finalize within max_age_ms of reserving and before expires_at. |
415
+ | `upload_not_found` | 422 | PUT the ciphertext to upload.url before finalizing; this object is now quarantined, so reserve again. |
416
+ | `upload_integrity_mismatch` | 422 | Upload exactly the reserved ciphertext (same sha256 and byte count); if the object is quarantined, reserve again. |
417
+ | `upload_ticket_invalid` | 403 | PUT to upload.url from your reserve response within expiresIn seconds; reserve again if it lapsed. |
418
+ | `upload_not_accepted` | 409 | This object is no longer accepting uploads; reserve a new object to send again. |
419
+ | `content_length_mismatch` | 411 | Send Content-Length equal to expected_bytes, the ciphertext_bytes you reserved. |
420
+ | `upload_too_large` | 413 | Send exactly the ciphertext_bytes you reserved, no more. |
421
+ | `upload_failed` | 400 | Retry the PUT to upload.url while its ticket is valid. |
422
+ | `not_recipient` | 403 | Redeem as the recipient_uuaid the sender named; only that identity may fetch this blob. |
423
+ | `blob_unavailable` | 409 | The sender has not finalized this object; ask the sender to send it again. |
424
+ | `blob_expired` | 410 | The blob is past its expires_at; ask the sender to send it again. |
425
+ | `redeem_limit_reached` | 410 | This blob has been redeemed max_redeems times; ask the sender to send it again. |
426
+ | `blob_not_found` | 404 | Check the object_id; the blob may have expired or never been finalized. |
427
+ | `stored_blob_missing` | 404, 422 | The broker no longer has this object; ask the sender to send it again. |
428
+ | `stored_blob_corrupt` | 422 | The stored object failed its integrity check; ask the sender to send it again. |
429
+ | `download_ticket_invalid` | 403 | Redeem again for a fresh download.url and GET it within expiresIn seconds. |
430
+ | `method_not_allowed` | 405 | PUT /objects/&lt;id> to upload and GET /objects/&lt;id> to download, each with its ticket query. |
431
+ | `not_found` | 404 | Use POST /pillar/blobs/{challenge,reserve,finalize,redeem}, PUT or GET /objects/&lt;id>, or GET /healthz. |
432
+ | `internal_error` | 500 | Retry once; if it fails again, report the time and path to the broker operator. |
package/README.md CHANGED
@@ -15,7 +15,7 @@ exchanges encrypted, signed envelopes through the pillar network — a
15
15
  store-and-forward carrier mesh with a LimeWire/BitTorrent-style seed topology:
16
16
  no single carrier is the only store.
17
17
 
18
- **Minimal requirements:** Node.js >= 20, outbound HTTPS. That's it. Carrier
18
+ **Minimal requirements:** Node.js >= 22, outbound HTTPS. That's it. Carrier
19
19
  mode needs no inbound ports, no daemon, no account. One identity = one
20
20
  keychain file + one SQLite mailbox.
21
21
 
@@ -399,14 +399,18 @@ rate and body size — see "Tier grants" above for the ladder. It is a
399
399
 
400
400
  ## Limits and hazards for integrators
401
401
 
402
+ - **Every number is in [`HOSTING-LIMITS.md`](HOSTING-LIMITS.md).** It covers
403
+ the long-poll cap and cadence, body sizes, rate windows, what 429/503/413
404
+ mean and what to do about each, push/wake, and proxy settings for hosts.
405
+ Each number cites its source line.
402
406
  - **One home, one live consumer.** A daemon-owned home directory and an
403
407
  interactive `pillar inbox` run against the same home can race on envelope
404
408
  id — run one consumer per home.
405
409
  - **Receipts are app-layer, not protocol-layer.** The `ack` envelope IS the
406
410
  receipt (see `INTERACTION-CONVENTION.md`, "carrier limitation #5"). The
407
- full enumerated list of carrier limitations that document cites by number
408
- is **not published** in this package — treat the citation as a pointer,
409
- not a spec.
411
+ numbered list that citation implies was never published, and no items #1-#4
412
+ exist in this tree. `HOSTING-LIMITS.md` is now the published list of what a
413
+ carrier enforces. It does not use that numbering.
410
414
  - **Carrier retention is time-bounded.** Envelopes are retained until TTL,
411
415
  default 14 days, then vacuumed (`src/net/carrier-server.mjs`).
412
416
  - **No metering.** The carrier's rate limiter is an in-memory sliding window
package/bin/carrier.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  * authenticate per-request with their own keys. No accounts, no state
11
11
  * about who exists — pure store-and-forward.
12
12
  */
13
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
13
14
  import { CarrierServer } from "../src/net/carrier-server.mjs";
14
15
 
15
16
  function parseArgs(argv) {
@@ -10,6 +10,7 @@
10
10
  * Exits 0 on reply(status ok), 3 on reply(status error), 4 on timeout.
11
11
  * See INTERACTION-CONVENTION.md for the wire contract.
12
12
  */
13
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
13
14
  import { existsSync, readFileSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
  import { homedir } from "node:os";
@@ -3,20 +3,33 @@
3
3
  * pillar-blob — encrypted temporary-object transport through the UUAID broker.
4
4
  *
5
5
  * pillar-blob send --to UUAID --recipient-pubkey HEX --data FILE [--broker URL]
6
- * [--carriers URL,...] --key-id REGISTRY_KEY_ID [--ttl-ms N]
7
- * pillar-blob get --ref-file FILE --expect-envelope ID --key-id REGISTRY_KEY_ID
6
+ * [--carriers URL,...] [--key-id KEY_ID] [--ttl-ms N]
7
+ * pillar-blob get --ref-file FILE --expect-envelope ID [--key-id KEY_ID]
8
8
  * [--broker URL] [--out FILE|-]
9
9
  *
10
+ * --key-id is optional. Without it (and without PILLAR_PRESENTATION_KEY_ID or
11
+ * config.presentationKeyId) the client presents the registry badge's key when
12
+ * a badge names this identity's key, else "self-certified:ed25519": every
13
+ * identity `pillar init` mints derives its UUAID from its key, so it needs no
14
+ * badge.
15
+ *
10
16
  * The broker never receives a DEK. `send` prints metadata only; the secret ref
11
17
  * exists solely inside the recipient-encrypted Pillar envelope.
18
+ *
19
+ * `get` names the sender on stderr. A self-certified sender (its UUAID is
20
+ * exactly the uuaid:foundation:agent UUAID of the key that signed the ref)
21
+ * is a proven key holder whose identity is UNVERIFIED: it is shown as its
22
+ * UUAID only, never with a name or badge mark from the registry.
12
23
  */
24
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
13
25
  import { readFileSync, writeFileSync } from "node:fs";
14
26
  import { homedir } from "node:os";
15
27
  import { join } from "node:path";
16
28
  import { Keychain } from "../src/identity/keychain.mjs";
17
29
  import { Pillar } from "../src/pillar.mjs";
18
- import { PillarBlobHttpClient } from "../src/blob/http.mjs";
30
+ import { PillarBlobHttpClient, BLOB_BROKERS, DEFAULT_BLOB_BROKER } from "../src/blob/http.mjs";
19
31
  import { fetchBrokerBlob, DEFAULT_BLOB_TTL_MS, BlobError } from "../src/blob/blob.mjs";
32
+ import { refusalLine } from "../src/net/refusals.mjs";
20
33
 
21
34
  function usage(code = 2) {
22
35
  process.stderr.write("usage: pillar-blob send|get (run without arguments to see the source header)\n");
@@ -43,16 +56,21 @@ function loadHome(args) {
43
56
  keychain.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
44
57
  let config = {};
45
58
  try { config = JSON.parse(readFileSync(join(home, "config.json"), "utf-8")); } catch (_error) {}
46
- const keyId = args["key-id"] ?? process.env.PILLAR_PRESENTATION_KEY_ID ?? config.presentationKeyId;
47
- if (!keyId) throw new Error("--key-id (registry-bound presentation key id) is required");
48
- const broker = args.broker ?? process.env.PILLAR_BLOB_BROKER ?? "https://api.uuaid.org";
59
+ // Optional: omitted, the client resolves badge key -> self-certified:ed25519.
60
+ const keyId = args["key-id"] ?? process.env.PILLAR_PRESENTATION_KEY_ID ?? config.presentationKeyId ?? null;
61
+ const broker = args.broker ?? process.env.PILLAR_BLOB_BROKER ?? DEFAULT_BLOB_BROKER;
49
62
  const client = new PillarBlobHttpClient({ baseUrl: broker, keychain, keyId });
50
- return { home, keychain, config, client };
63
+ return { home, keychain, config, client, keyId };
51
64
  }
52
65
 
66
+ // A broker or carrier refusal prints one line, `<code>: <fix>`: the broker's
67
+ // code/fix, or the carrier's reason and its fix (src/net/refusals.mjs).
68
+ // Anything else keeps the old `pillar-blob: <code>: <message>` line. Exit
69
+ // codes follow the client's own code (legacyCode when the broker renamed it),
70
+ // so they are what they were before alpha.18.
53
71
  function fail(error) {
54
- const code = error instanceof BlobError ? error.code : "error";
55
- process.stderr.write(`pillar-blob: ${code}: ${error?.message ?? String(error)}\n`);
72
+ const code = error instanceof BlobError ? (error.legacyCode ?? error.code) : "error";
73
+ process.stderr.write(`${refusalLine(error) ?? `pillar-blob: ${code}: ${error?.message ?? String(error)}`}\n`);
56
74
  process.exit(code === "blob-not-found" || code === "blob-expired" ? 3 : code.includes("integrity") ? 5 : 6);
57
75
  }
58
76
 
@@ -93,9 +111,16 @@ try {
93
111
  }
94
112
  } else if (command === "get") {
95
113
  if (!args["ref-file"] || !args["expect-envelope"]) usage();
96
- const { keychain, client } = loadHome(args);
114
+ const loaded = loadHome(args);
115
+ const { keychain } = loaded;
116
+ let { client } = loaded;
97
117
  const parsed = JSON.parse(readFileSync(args["ref-file"], "utf-8"));
98
118
  const ref = parsed.blobRef ?? parsed;
119
+ // With no --broker or PILLAR_BLOB_BROKER, redeem where the sender stored it,
120
+ // if that is one of the known brokers; any other broker must be named explicitly.
121
+ if (!args.broker && !process.env.PILLAR_BLOB_BROKER && BLOB_BROKERS.includes(ref?.broker)) {
122
+ client = new PillarBlobHttpClient({ baseUrl: ref.broker, keychain, keyId: loaded.keyId });
123
+ }
99
124
  const result = await fetchBrokerBlob({
100
125
  ref,
101
126
  envelopeId: args["expect-envelope"],
@@ -104,7 +129,9 @@ try {
104
129
  });
105
130
  if (!args.out || args.out === "-") process.stdout.write(result.data);
106
131
  else writeFileSync(args.out, result.data, { mode: 0o600 });
107
- process.stderr.write(`pillar-blob: redeemed ${result.size} plaintext byte(s)\n`);
132
+ const sender = String(result.sender).replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
133
+ const proof = result.senderProof === "registry-badge" ? "registry badge" : "unverified identity, self-certified key";
134
+ process.stderr.write(`pillar-blob: redeemed ${result.size} plaintext byte(s) from ${sender} (${proof})\n`);
108
135
  } else usage();
109
136
  } catch (error) {
110
137
  fail(error);
@@ -22,6 +22,7 @@
22
22
  * `agents.json` is passed through untouched: this process owns the seed list,
23
23
  * not the address book.
24
24
  */
25
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
25
26
  import { createServer } from "node:http";
26
27
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
27
28
  import { homedir } from "node:os";
@@ -42,6 +42,7 @@
42
42
  * (who absorbs the fee: the seller, the buyer, or 50/50) or --payout N (the
43
43
  * parties' own number; a "mutual" agreement). No default is ever derived.
44
44
  */
45
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
45
46
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
46
47
  import { join, dirname } from "node:path";
47
48
  import { homedir } from "node:os";
@@ -31,6 +31,7 @@
31
31
  * --reply-text "zz-master is away — left a message" \
32
32
  * --forward-to uuaid:foundation:agent:… --forward-pubkey <hex>
33
33
  */
34
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
34
35
  import { existsSync, readFileSync, appendFileSync } from "node:fs";
35
36
  import { join } from "node:path";
36
37
  import { homedir } from "node:os";
@@ -39,6 +39,7 @@
39
39
  * TLS-terminating proxy (nginx/Caddy) and pass the PUBLIC https:// URL as
40
40
  * --public-url — never the loopback one.
41
41
  */
42
+ import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
42
43
  import { mkdirSync } from "node:fs";
43
44
  import { homedir } from "node:os";
44
45
  import { join } from "node:path";