@uuaid/pillar 2.0.3 → 2.3.0

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.
@@ -20,6 +20,21 @@ function byTier(tiers) {
20
20
  return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
21
21
  }
22
22
 
23
+ /**
24
+ * The carrier's signed acceptance receipt (uuaid.carrier-receipt.v1), kept as
25
+ * the carrier sent it, plus this client's own clock when the answer arrived
26
+ * (design §12 rule 3: record received-at beside the carrier's acceptedAt,
27
+ * which is only the carrier's claim). NOT verified here: verify against the
28
+ * pinned key for this carrier and the exact envelope posted. {} when the
29
+ * carrier sent no receipt (an older or keyless carrier): that never fails a
30
+ * delivery, the outcome is simply unreceipted.
31
+ */
32
+ function keptReceipt(body) {
33
+ const r = body?.receipt;
34
+ if (!r || typeof r !== "object" || Array.isArray(r) || typeof body.receiptSig !== "string") return {};
35
+ return { receipt: r, receiptSig: body.receiptSig, receivedAt: new Date().toISOString() }; // @rule client.keeps-receipt
36
+ }
37
+
23
38
  export class CarrierClient {
24
39
  /**
25
40
  * @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
@@ -66,8 +81,13 @@ export class CarrierClient {
66
81
  * Resolves once every carrier has answered. Succeeds if at least one
67
82
  * accepted; throws if every carrier rejects. Returns the receipt from the
68
83
  * most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
69
- * duplicate }) plus `all` with every carrier's outcome ({ carrier, ok,
70
- * seq?, sha?, duplicate?, error?, status?, reason?, fix? }).
84
+ * duplicate, receipt?, receiptSig?, receivedAt? }) plus `all` with every
85
+ * carrier's outcome ({ carrier, ok, seq?, sha?, duplicate?, receipt?,
86
+ * receiptSig?, receivedAt?, error?, status?, reason?, fix? }).
87
+ *
88
+ * `receipt` / `receiptSig` are present only when that carrier signed one
89
+ * (uuaid.carrier-receipt.v1); they are unverified carrier output, and
90
+ * `receivedAt` is this client's clock, not the carrier's.
71
91
  *
72
92
  * When every carrier refuses, the thrown error carries `code` (the most
73
93
  * proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
@@ -105,7 +125,7 @@ export class CarrierClient {
105
125
  let body = {};
106
126
  try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
107
127
  if (res.status === 202 && body.accepted) {
108
- return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
128
+ return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate, ...keptReceipt(body) };
109
129
  }
110
130
  const reason = typeof body.reason === "string" && body.reason ? body.reason
111
131
  : typeof body.error === "string" && body.error ? body.error : null;
@@ -132,15 +152,15 @@ export class CarrierClient {
132
152
  throw e;
133
153
  }
134
154
  const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
135
- const { carrier, seq, sha, duplicate } = ranked[0];
136
- return { carrier, seq, sha, duplicate, all };
155
+ const { carrier, seq, sha, duplicate, receipt, receiptSig, receivedAt } = ranked[0];
156
+ return { carrier, seq, sha, duplicate, ...(receipt ? { receipt, receiptSig, receivedAt } : {}), all };
137
157
  }
138
158
 
139
159
  /**
140
160
  * Fetch envelopes addressed to us with seq > since from a specific carrier.
141
161
  * Signed request; carrier verifies our key before serving.
142
162
  */
143
- async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
163
+ async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000, signal = null } = {}) {
144
164
  const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
145
165
  const ts = String(Date.now());
146
166
  const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
@@ -150,7 +170,9 @@ export class CarrierClient {
150
170
  "x-pillar-ts": ts,
151
171
  "x-pillar-sig": sig,
152
172
  },
153
- signal: AbortSignal.timeout(timeoutMs),
173
+ // `signal` lets a caller cancel a long-poll early (peekCarrierInbox stops
174
+ // the other carriers once one has mail).
175
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
154
176
  });
155
177
  if (res.status !== 200) {
156
178
  const body = await res.json().catch(() => ({}));
@@ -0,0 +1,77 @@
1
+ /**
2
+ * uuaid.carrier-receipt.v1 — the carrier's signed acceptance receipt.
3
+ *
4
+ * Design: uuaid docs/designs/2026-10-18-carrier-acceptance-receipt.md (§2 wire,
5
+ * §12 binding rules). A carrier configured with a receipt key answers an
6
+ * accepted POST /v1/envelopes with `receipt` + `receiptSig`, where receiptSig
7
+ * is Ed25519 (hex) over the UTF-8 bytes of
8
+ *
9
+ * "uuaid.carrier-receipt.v1" + "\n" + JCS({ the 10 fields below })
10
+ *
11
+ * The fixed prefix is domain separation: a receipt can never be mistaken for
12
+ * the seed identity document (bare JCS, first byte "{"), an auth.v1 assertion
13
+ * or a directory listing, even when the same key signs them.
14
+ *
15
+ * A receipt is the carrier key holder's attributable claim of acceptance,
16
+ * sequence, time and a retention commitment. It does not prove delivery, the
17
+ * disk write or the clock (§12 rule 3).
18
+ *
19
+ * Verification lives in the clients (@uuaid/pillar-client verifyCarrierReceipt);
20
+ * this module is what the carrier signs, and is byte-identical to the
21
+ * pillar-client `carrierReceiptSigningString` (checked against the shared
22
+ * vectors in test/unit/carrier-receipt.test.mjs).
23
+ */
24
+ import { jcs } from "../identity/jcs.mjs";
25
+ import { SELF_CERTIFYING_NAMESPACES, localIdFromKey } from "../identity/keychain.mjs";
26
+
27
+ export const CARRIER_RECEIPT_TYPE = "uuaid.carrier-receipt.v1";
28
+
29
+ /** The exact receipt keys, in JCS (sorted) order. */
30
+ export const CARRIER_RECEIPT_FIELDS = Object.freeze([
31
+ "acceptedAt", "carrier", "duplicate", "envelopeId", "envelopeSha",
32
+ "recipient", "retainUntil", "sender", "seq", "v",
33
+ ]);
34
+
35
+ const ISO_MS_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
36
+ const HEX64 = /^[0-9a-f]{64}$/;
37
+ const AGENT_UUAID = new RegExp(`^uuaid:(${SELF_CERTIFYING_NAMESPACES.join("|")}):agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`);
38
+ const isParty = (s) => typeof s === "string" && s.length > "uuaid:".length && s.startsWith("uuaid:");
39
+
40
+ /** The exact string whose UTF-8 bytes the carrier signs. Only the 10 receipt keys are covered. */
41
+ export function carrierReceiptSigningString(fields) {
42
+ const body = {};
43
+ for (const k of CARRIER_RECEIPT_FIELDS) body[k] = fields[k];
44
+ return `${CARRIER_RECEIPT_TYPE}\n${jcs(body)}`;
45
+ }
46
+
47
+ /** The carrier uuaid a receipt key derives: uuaid:foundation:agent:<localIdFromKey(key)>. */
48
+ export function carrierUuaidFromKey(publicKeyHex) {
49
+ return `uuaid:foundation:agent:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
50
+ }
51
+
52
+ function isIsoMs(s) {
53
+ return typeof s === "string" && ISO_MS_Z.test(s) && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s;
54
+ }
55
+
56
+ /**
57
+ * True when `r` is a receipt a v1 verifier can accept on shape alone: exactly
58
+ * the 10 keys, v 1, seq a safe integer >= 1, boolean duplicate, ISO-8601 UTC
59
+ * millisecond times with retainUntil after acceptedAt, a key-derived agent
60
+ * uuaid as carrier, a non-empty envelopeId, a 64-hex envelopeSha and uuaid
61
+ * parties: the same schema rules as the pillar-client verifier. The carrier
62
+ * checks this before signing, so it never signs a claim no client could verify.
63
+ */
64
+ export function carrierReceiptShapeOk(r) {
65
+ if (!r || typeof r !== "object" || Array.isArray(r)) return false;
66
+ const keys = Object.keys(r).sort();
67
+ if (keys.length !== CARRIER_RECEIPT_FIELDS.length || keys.some((k, i) => k !== CARRIER_RECEIPT_FIELDS[i])) return false;
68
+ if (r.v !== 1) return false;
69
+ if (!Number.isSafeInteger(r.seq) || r.seq < 1) return false;
70
+ if (typeof r.duplicate !== "boolean") return false;
71
+ if (!isIsoMs(r.acceptedAt) || !isIsoMs(r.retainUntil)) return false;
72
+ if (!(Date.parse(r.retainUntil) > Date.parse(r.acceptedAt))) return false;
73
+ if (typeof r.carrier !== "string" || !AGENT_UUAID.test(r.carrier)) return false;
74
+ if (typeof r.envelopeId !== "string" || !r.envelopeId) return false;
75
+ if (typeof r.envelopeSha !== "string" || !HEX64.test(r.envelopeSha)) return false;
76
+ return isParty(r.sender) && isParty(r.recipient);
77
+ }
@@ -12,6 +12,13 @@
12
12
  * - carrier verifies the transport signature before accepting (fail closed)
13
13
  * - idempotent on envelope.id
14
14
  * -> 202 { accepted: true, seq, sha }
15
+ * With a receipt key (constructor `receiptKeychain`), the 202 also carries
16
+ * `receipt` + `receiptSig` (uuaid.carrier-receipt.v1, ./carrier-receipt.mjs),
17
+ * built from the STORED row after the insert commits. A repeated
18
+ * envelope.id whose canonical hash (sha256(JCS(envelope))) equals the
19
+ * stored one gets the original fields with duplicate: true; a different
20
+ * body under a stored id is refused 409 envelope-id-conflict, no receipt.
21
+ * Without a receipt key every response is exactly as before.
15
22
  *
16
23
  * GET /v1/inbox/<uuaid>?since=<seq>&wait=<seconds>
17
24
  * headers:
@@ -46,6 +53,9 @@
46
53
  * only becomes visible at the moment the carrier starts saying no.
47
54
  *
48
55
  * Durability: SQLite (WAL). Envelopes are retained until TTL (default 14 days).
56
+ * Each row stores its own `retain_until` (created_at + TTL at acceptance), and
57
+ * vacuum() deletes a row only once now > that row's retain_until, so a later,
58
+ * smaller TTL never shortens a deadline already issued in a receipt.
49
59
  * The carrier CANNOT read payloads it relays beyond what the envelope exposes,
50
60
  * and CANNOT forge envelopes (no keys). It can only: deliver, delay, or drop —
51
61
  * and drops are detectable by sequence gaps + sender outbox audit.
@@ -53,12 +63,13 @@
53
63
  import { createServer } from "node:http";
54
64
  import { createHash } from "node:crypto";
55
65
  import Database from "better-sqlite3";
56
- import { open as openEnvelope } from "./envelope.mjs";
57
- import { Keychain } from "../identity/keychain.mjs";
66
+ import { open as openEnvelope, envelopeSha as canonicalEnvelopeSha } from "./envelope.mjs";
67
+ import { Keychain, keyDerivesUuaid, isStrictEd25519PublicKey } from "../identity/keychain.mjs";
58
68
  import { jcs } from "../identity/jcs.mjs";
59
69
  import { verifyTierGrant } from "../identity/tier.mjs";
60
70
  import { verifyDelegationDoc, delegationCoversKind } from "./delegation.mjs";
61
71
  import { withCarrierFix } from "./refusals.mjs"; // additive `fix` on refusals; reason/error/status unchanged
72
+ import { carrierReceiptSigningString, carrierReceiptShapeOk, carrierUuaidFromKey } from "./carrier-receipt.mjs";
62
73
 
63
74
  const MAX_WAIT_S = 30;
64
75
  const MAX_BODY = 512 * 1024; // 512 KiB per envelope (community tier)
@@ -79,10 +90,27 @@ const RATE_MAP_HARD_CAP = 10_000; // F2: bound memory under key spray
79
90
  // still fits inside the same absolute ceiling. A carrier with no trusted tier
80
91
  // issuers configured never resolves a priority grant, so these are inert.
81
92
  const COMMUNITY_DB_BYTES = Math.floor(MAX_DB_BYTES * 0.85); // last 15% of the store is reserved
82
- const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS
93
+ const COMMUNITY_CONCURRENT_WAITS = 48; // of MAX_CONCURRENT_WAITS (the default)
94
+ // A carrier operator may raise the community long-poll cap (CARRIER_COMMUNITY_WAITS;
95
+ // pillar.uuaid.org runs 192, grant ZZ-DP-2026-10-22-007). The priority reserve is kept
96
+ // ON TOP of it, so a raised community cap never eats the reserved slots.
97
+ const PRIORITY_WAIT_RESERVE = MAX_CONCURRENT_WAITS - COMMUNITY_CONCURRENT_WAITS; // 16
98
+ const MAX_COMMUNITY_WAITS = 4096; // sanity ceiling for the setting
99
+
100
+ /** Parse a community long-poll cap: a whole number from 1 to 4096, else throw. */
101
+ export function parseCommunityWaits(value) {
102
+ if (value === undefined || value === null || value === "") return COMMUNITY_CONCURRENT_WAITS;
103
+ const n = typeof value === "number" ? value : (/^[0-9]+$/.test(String(value)) ? Number(value) : NaN);
104
+ if (!Number.isInteger(n) || n < 1 || n > MAX_COMMUNITY_WAITS) {
105
+ throw new Error(`community waits must be a whole number from 1 to ${MAX_COMMUNITY_WAITS}, not "${value}"`);
106
+ }
107
+ return n;
108
+ }
83
109
  const MAX_PRIORITY_GOOD_PER_MIN = 600; // priority senders bill to their own per-IP bucket
84
110
  const CHECKPOINT_ATTEMPTS = 3; // TRUNCATE checkpoint tries per vacuum pass
85
111
  const CHECKPOINT_BUSY_MS = 250; // per try; a busy pass is retried next hour
112
+ /** Row columns a receipt is built from (plus the legacy `sha` the 202 has always echoed). */
113
+ const ROW_COLUMNS = "seq, envelope_id, recipient, sender, sha, created_at, retain_until, envelope_sha";
86
114
 
87
115
  /**
88
116
  * Secure delete for the envelope store: one switch per broker (--secure-delete
@@ -108,7 +136,22 @@ export function parseSecureDelete(value) {
108
136
  }
109
137
 
110
138
  export class CarrierServer {
111
- constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null, secureDelete = true } = {}) {
139
+ constructor({ dbPath = "carrier.db", ttlDays = 14, cryptoInventory, trustedTierIssuers = [], keychain = null, publicUrl = null, secureDelete = true, receiptKeychain = null, communityInboxWaits = COMMUNITY_CONCURRENT_WAITS } = {}) {
140
+ // Every row's retain_until is created_at + ttlDays, so the TTL must be a
141
+ // real positive duration (a NaN here used to surface only as a vacuum crash).
142
+ if (typeof ttlDays !== "number" || !Number.isFinite(ttlDays) || ttlDays <= 0) {
143
+ throw new Error(`ttlDays must be a positive number of days, not ${ttlDays}`);
144
+ }
145
+ // Receipt key: optional and separate from the seed `keychain`. Fail closed
146
+ // on a key that is not loaded or not a strict Ed25519 key, rather than
147
+ // running a carrier that silently signs nothing or signs under a weak key.
148
+ if (receiptKeychain !== null && receiptKeychain !== undefined) {
149
+ const id = receiptKeychain._identity;
150
+ if (!id?.publicKeyHex || typeof receiptKeychain.sign !== "function") {
151
+ throw new Error("receiptKeychain must be a loaded Keychain");
152
+ }
153
+ if (!isStrictEd25519PublicKey(id.publicKeyHex)) throw new Error("receiptKeychain key is not a strict Ed25519 key");
154
+ }
112
155
  this.db = new Database(dbPath);
113
156
  this.db.pragma("journal_mode = WAL");
114
157
  this.secureDelete = secureDelete !== false;
@@ -122,7 +165,9 @@ export class CarrierServer {
122
165
  sender text not null,
123
166
  sha text not null,
124
167
  body text not null,
125
- created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
168
+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
169
+ retain_until text,
170
+ envelope_sha text
126
171
  );
127
172
  create index if not exists idx_envelopes_recipient on envelopes(recipient, seq);
128
173
  -- Lifetime service counters. Kept OUTSIDE the envelopes table because
@@ -133,14 +178,25 @@ export class CarrierServer {
133
178
  v text not null
134
179
  );
135
180
  `);
181
+ // Migration for stores created before carrier receipts: add the per-row
182
+ // retention deadline and canonical envelope hash. Existing rows get
183
+ // retain_until = created_at + ttlDays (what the old vacuum would have
184
+ // honoured); their canonical hash is computed from the stored body when a
185
+ // duplicate first needs it (_storedCanonicalSha).
186
+ const cols = new Set(this.db.prepare("pragma table_info(envelopes)").all().map(c => c.name));
187
+ if (!cols.has("retain_until")) this.db.exec("alter table envelopes add column retain_until text");
188
+ if (!cols.has("envelope_sha")) this.db.exec("alter table envelopes add column envelope_sha text");
136
189
  this.db.prepare("insert or ignore into seed_stats (k, v) values ('first_seen', ?)")
137
190
  .run(new Date().toISOString());
138
191
  this.ttlDays = ttlDays;
192
+ this._backfillRetention(); // @rule carrier.retention-backfill
139
193
  this.cryptoInventory = cryptoInventory ?? { ed25519: "active" };
140
194
  this.startedAt = Date.now();
141
195
  this.server = null;
142
196
  this.rateMap = new Map(); // F2: "<kind>:<key>" -> [ms timestamps]
143
197
  this.activeWaits = 0; // F2: long-polls currently sleeping
198
+ this.communityWaitCap = parseCommunityWaits(communityInboxWaits);
199
+ this.priorityWaitCap = this.communityWaitCap + PRIORITY_WAIT_RESERVE;
144
200
  this._closed = false;
145
201
  // Tier grants: issuer pubkeys (hex) this carrier honors. Empty = every
146
202
  // sender gets community-tier budgets (pre-tier behavior, default).
@@ -150,6 +206,75 @@ export class CarrierServer {
150
206
  // does not expose /v1/seed-info.
151
207
  this.keychain = keychain;
152
208
  this.publicUrl = publicUrl ? String(publicUrl).replace(/\/+$/, "") : null;
209
+ // Receipt identity: the carrier uuaid is DERIVED from the receipt key
210
+ // (uuaid:foundation:agent:<localId>), never read from the keychain file,
211
+ // so it is always the uuaid a client's pin check derives.
212
+ this.receiptKeychain = receiptKeychain ?? null;
213
+ this.receiptCarrier = this.receiptKeychain ? carrierUuaidFromKey(this.receiptKeychain._identity.publicKeyHex) : null;
214
+ }
215
+
216
+ /** created_at + the current TTL, as ISO-8601 UTC with milliseconds; null if created_at does not parse. */
217
+ _retainUntilFor(createdAt) {
218
+ const ms = Date.parse(createdAt);
219
+ if (!Number.isFinite(ms)) return null;
220
+ return new Date(ms + this.ttlDays * 86400_000).toISOString();
221
+ }
222
+
223
+ /**
224
+ * Give every row without a retention deadline one: created_at + ttlDays.
225
+ * Only rows that never had a deadline are touched (rows from before this
226
+ * column existed, or written by hand); an issued deadline is never changed.
227
+ */
228
+ _backfillRetention() {
229
+ const rows = this.db.prepare("select seq, created_at from envelopes where retain_until is null").all();
230
+ if (!rows.length) return 0;
231
+ const set = this.db.prepare("update envelopes set retain_until = ? where seq = ? and retain_until is null");
232
+ let n = 0;
233
+ this.db.transaction(() => {
234
+ for (const r of rows) {
235
+ const until = this._retainUntilFor(r.created_at);
236
+ if (until !== null) n += set.run(until, r.seq).changes;
237
+ }
238
+ })();
239
+ return n;
240
+ }
241
+
242
+ /**
243
+ * The canonical hash (sha256(JCS(envelope))) of a stored row: the column
244
+ * when set, else computed once from the stored body and persisted. Null if
245
+ * the stored body cannot be canonicalized.
246
+ */
247
+ _storedCanonicalSha(row) {
248
+ if (typeof row.envelope_sha === "string") return row.envelope_sha;
249
+ let h = null;
250
+ try { h = canonicalEnvelopeSha(JSON.parse(row.body)); } catch (_e) { h = null; }
251
+ if (h !== null) this.db.prepare("update envelopes set envelope_sha = ? where seq = ? and envelope_sha is null").run(h, row.seq);
252
+ return h;
253
+ }
254
+
255
+ /**
256
+ * { receipt, receiptSig } for a stored row, or null. Every field comes from
257
+ * the committed row (§12 rule 1: sign the stored object, never the request
258
+ * body); only `duplicate` says which path asked. A row whose fields would
259
+ * not pass a v1 verifier is not signed.
260
+ */
261
+ _receiptFor(row, duplicate) {
262
+ if (!this.receiptKeychain) return null;
263
+ const receipt = {
264
+ v: 1,
265
+ carrier: this.receiptCarrier,
266
+ envelopeId: row.envelope_id,
267
+ envelopeSha: row.envelope_sha, // @rule carrier.receipt-canonical-sha
268
+ sender: row.sender,
269
+ recipient: row.recipient,
270
+ seq: Number(row.seq),
271
+ duplicate: duplicate === true,
272
+ acceptedAt: row.created_at, // @rule carrier.receipt-stored-time
273
+ retainUntil: row.retain_until,
274
+ };
275
+ if (!carrierReceiptShapeOk(receipt)) return null;
276
+ const receiptSig = this.receiptKeychain.sign(Buffer.from(carrierReceiptSigningString(receipt), "utf-8")).toString("hex");
277
+ return { receipt, receiptSig };
153
278
  }
154
279
 
155
280
  /** Read a lifetime counter. */
@@ -264,15 +389,22 @@ export class CarrierServer {
264
389
  }
265
390
 
266
391
  /**
267
- * Remove envelopes older than TTL. Call periodically. Returns the number
268
- * deleted. With secure delete on, a pass that deleted anything then runs
392
+ * Remove envelopes whose own retention deadline has passed (now >
393
+ * retain_until). Call periodically. Returns the number deleted. A row's
394
+ * deadline is fixed when it is stored, so lowering ttlDays later never
395
+ * deletes a row before the retainUntil its receipt promised. A row with no
396
+ * parseable created_at (so no deadline) falls back to the old created_at
397
+ * cutoff. With secure delete on, a pass that deleted anything then runs
269
398
  * wal_checkpoint(TRUNCATE) until it reports busy=0 (at most
270
399
  * CHECKPOINT_ATTEMPTS tries); `lastVacuum` records the outcome, and
271
400
  * `lastVacuum.scrubbed` is false when the checkpoint never completed.
272
401
  */
273
402
  vacuum() {
403
+ this._backfillRetention();
404
+ const now = new Date().toISOString();
274
405
  const cutoff = new Date(Date.now() - this.ttlDays * 86400_000).toISOString();
275
- const r = this.db.prepare("delete from envelopes where created_at < ?").run(cutoff);
406
+ const expired = "retain_until < @now or (retain_until is null and created_at < @cutoff)"; // @rule carrier.retention-per-row
407
+ const r = this.db.prepare(`delete from envelopes where ${expired}`).run({ now, cutoff });
276
408
  const report = { deleted: r.changes, secureDelete: this.secureDelete, checkpoint: null, attempts: 0, scrubbed: null };
277
409
  if (this.secureDelete && r.changes > 0) {
278
410
  // vacuum() runs on the event loop: wait at most CHECKPOINT_BUSY_MS per
@@ -361,20 +493,45 @@ export class CarrierServer {
361
493
  const pages = this.db.pragma("page_count", { simple: true });
362
494
  const pageBytes = this.db.pragma("page_size", { simple: true });
363
495
  const storeCap = tier.priority ? MAX_DB_BYTES : COMMUNITY_DB_BYTES;
364
- if (pages * pageBytes > storeCap) {
365
- return { code: 503, body: { accepted: false, reason: "carrier-full" } };
366
- }
496
+ if (pages * pageBytes > storeCap) return { code: 503, body: { accepted: false, reason: "carrier-full" } }; // @rule carrier.full-refuses-before-store
367
497
  const sha = createHash("sha256").update(JSON.stringify(envelope)).digest("hex");
498
+ // Canonical hash, independent of the key order the envelope arrived in.
499
+ // A carrier that signs receipts must be able to bind the envelope, so it
500
+ // refuses one it cannot canonicalize; without a receipt key such an
501
+ // envelope is stored exactly as before.
502
+ let canonicalSha = null;
503
+ try { canonicalSha = canonicalEnvelopeSha(envelope); } catch (_e) { canonicalSha = null; }
504
+ if (this.receiptKeychain && canonicalSha === null) {
505
+ return { code: 400, body: { accepted: false, reason: "bad-canonical" } };
506
+ }
507
+ const acceptedAt = new Date().toISOString();
508
+ const retainUntil = this._retainUntilFor(acceptedAt);
368
509
  try {
369
510
  const r = this.db.prepare(
370
- "insert into envelopes (envelope_id, recipient, sender, sha, body) values (?,?,?,?,?)"
371
- ).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope));
511
+ "insert into envelopes (envelope_id, recipient, sender, sha, body, created_at, retain_until, envelope_sha) values (?,?,?,?,?,?,?,?)"
512
+ ).run(envelope.id, envelope.recipient, envelope.sender, sha, JSON.stringify(envelope), acceptedAt, retainUntil, canonicalSha);
372
513
  this._bump("relayed");
373
- return { code: 202, body: { accepted: true, seq: Number(r.lastInsertRowid), sha } };
514
+ const seq = Number(r.lastInsertRowid);
515
+ const body = { accepted: true, seq, sha };
516
+ if (this.receiptKeychain) {
517
+ // Re-read the committed row: the receipt is built from what is stored.
518
+ const row = this.db.prepare(`select ${ROW_COLUMNS} from envelopes where seq = ?`).get(seq);
519
+ const signed = row ? this._receiptFor(row, false) : null;
520
+ if (signed) Object.assign(body, signed);
521
+ }
522
+ return { code: 202, body };
374
523
  } catch (e) {
375
524
  if (/UNIQUE constraint/.test(e.message)) {
376
- const row = this.db.prepare("select seq, sha from envelopes where envelope_id = ?").get(envelope.id);
377
- return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha } };
525
+ const row = this.db.prepare(`select ${ROW_COLUMNS}, body from envelopes where envelope_id = ?`).get(envelope.id);
526
+ // No receipt key: the duplicate answer is exactly the pre-receipt one.
527
+ if (!this.receiptKeychain) return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha } }; // @rule carrier.keyless-unchanged
528
+ // §12 rule 1 (P1): the same id with a different body is not a
529
+ // duplicate. Refuse it, unsigned, rather than receipt the stored body
530
+ // to a sender who posted something else.
531
+ const storedSha = this._storedCanonicalSha(row);
532
+ if (storedSha === null || storedSha !== canonicalSha) return { code: 409, body: { accepted: false, reason: "envelope-id-conflict" } }; // @rule carrier.id-conflict
533
+ const signed = this._receiptFor({ ...row, envelope_sha: storedSha }, true);
534
+ return { code: 202, body: { accepted: true, duplicate: true, seq: row.seq, sha: row.sha, ...(signed ?? {}) } };
378
535
  }
379
536
  throw e;
380
537
  }
@@ -392,9 +549,7 @@ export class CarrierServer {
392
549
  return { ok: false, reason: "clock-skew" };
393
550
  }
394
551
  // Self-authenticating: UUAID must derive from the presented pubkey.
395
- const localId = Keychain._localIdFromKey(Buffer.from(pubkey, "hex"));
396
- const derived = `uuaid:foundation:agent:${localId}`;
397
- if (derived !== uuaid) {
552
+ if (!keyDerivesUuaid(pubkey, uuaid, "agent")) {
398
553
  // A delegate key may read the principal's inbox only by presenting the
399
554
  // principal-signed delegation document that names it. Without the
400
555
  // header this stays the legacy mismatch.
@@ -457,7 +612,7 @@ export class CarrierServer {
457
612
  // delegate polling for a priority principal inherits the reserved slots,
458
613
  // which is the same authority it already has to read the mail.
459
614
  const tier = this._resolveTier(req, uuaid);
460
- const waitCap = tier.priority ? MAX_CONCURRENT_WAITS : COMMUNITY_CONCURRENT_WAITS;
615
+ const waitCap = tier.priority ? this.priorityWaitCap : this.communityWaitCap;
461
616
  if (wait > 0 && this.activeWaits >= waitCap) {
462
617
  return this._json(res, 503, { error: "busy" });
463
618
  }
@@ -34,6 +34,7 @@ export const REFUSAL_FIXES = Object.freeze({
34
34
  "bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
35
35
  "weak-publicKey": "use a real Ed25519 identity key; this key is a small-order or non-canonical point that can sign anything, so no signature under it counts",
36
36
  "delegation-invalid": "mint a fresh delegation from the principal, then resend",
37
+ "envelope-id-conflict": "seal a new envelope with a fresh id; this carrier already holds a different envelope under this id",
37
38
  // Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
38
39
  "not-object": RESEAL,
39
40
  "bad-version": RESEAL,
@@ -69,8 +70,9 @@ export const REFUSAL_FIXES = Object.freeze({
69
70
  "carrier-unreachable": "check the carrier URLs and your network, then retry",
70
71
  "carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
71
72
  "over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
72
- "blob-key-unproven": "use a uuaid:foundation:agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
73
- "sender-key-not-active": "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",
73
+ "blob-key-unproven": "use a self-certifying agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
74
+ "sender-key-not-active": "ask the sender to send from a self-certifying agent UUAID derived from its own key, or to get a registry badge naming its key",
75
+ "group-v2-use-v2-send": "send to this pillar-group/2 group with pillar group send <group>; v1 group sends (send-group, sendGroup) do not reach it",
74
76
  });
75
77
 
76
78
  /**