@staix/agent-hub 0.30.0 → 0.32.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +11 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +178 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +23 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +355 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +24 -5
  21. package/docs/security.md +178 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +127 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +311 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-0.32.0.md +149 -0
  30. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  31. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  32. package/docs/verified.json +14 -14
  33. package/package.json +3 -4
  34. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  35. package/plugins/agent-hub/server.js +4393 -3609
  36. package/src/adapters/acp.ts +5 -4
  37. package/src/adapters/channel-peer.ts +72 -3
  38. package/src/adapters/claude-channel-evidence.ts +52 -0
  39. package/src/adapters/claude-channel.ts +61 -18
  40. package/src/adapters/claude-implementer.ts +29 -7
  41. package/src/adapters/claude-print-child.ts +12 -2
  42. package/src/adapters/codex-appserver.ts +13 -12
  43. package/src/adapters/compat.ts +3 -3
  44. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  45. package/src/adapters/definitions/qwen.ts +1 -0
  46. package/src/adapters/local-worker.ts +61 -18
  47. package/src/adapters/pi.ts +48 -6
  48. package/src/adapters/registry-types.ts +12 -0
  49. package/src/adapters/registry.ts +2 -0
  50. package/src/adapters/reviewer-print.ts +50 -11
  51. package/src/broker/server.ts +44 -13
  52. package/src/broker/store.ts +38 -8
  53. package/src/cli/answer-args.ts +50 -0
  54. package/src/cli/attention.ts +17 -2
  55. package/src/cli/backlog-return-args.ts +37 -0
  56. package/src/cli/console-question.ts +110 -0
  57. package/src/cli/console-state.ts +78 -17
  58. package/src/cli/console.ts +54 -1
  59. package/src/cli/handoff-args.ts +15 -5
  60. package/src/cli/handoff-output.ts +15 -2
  61. package/src/cli/help.ts +8 -5
  62. package/src/cli/identity.ts +13 -3
  63. package/src/cli/main.ts +128 -16
  64. package/src/cli/memory.ts +107 -3
  65. package/src/cli/output.ts +29 -6
  66. package/src/cli/pet.ts +4 -4
  67. package/src/cli/results-args.ts +30 -0
  68. package/src/cli/room.ts +20 -10
  69. package/src/cli/runtime-version-warning.ts +50 -0
  70. package/src/cli/status-lines.ts +41 -1
  71. package/src/cli/tail-render.ts +1 -0
  72. package/src/cli/upgrade-interactive.ts +14 -2
  73. package/src/hub/action-class.ts +9 -1
  74. package/src/hub/board.ts +369 -18
  75. package/src/hub/bus.ts +511 -53
  76. package/src/hub/completion-detail.ts +50 -0
  77. package/src/hub/completions-store.ts +168 -0
  78. package/src/hub/completions.ts +120 -0
  79. package/src/hub/config-apply.ts +7 -5
  80. package/src/hub/config-reload.ts +10 -1
  81. package/src/hub/control-client.ts +40 -4
  82. package/src/hub/daemon.ts +1576 -139
  83. package/src/hub/delivery-failure.ts +14 -0
  84. package/src/hub/delivery-hints.ts +14 -4
  85. package/src/hub/delivery-journal.ts +751 -42
  86. package/src/hub/delivery-superseded.ts +4 -1
  87. package/src/hub/envelope.ts +3 -3
  88. package/src/hub/events.ts +6 -0
  89. package/src/hub/handoff.ts +145 -6
  90. package/src/hub/hub-tools.ts +56 -7
  91. package/src/hub/manager.ts +59 -3
  92. package/src/hub/memory-policy.ts +44 -1
  93. package/src/hub/memory-sync-policy.ts +109 -0
  94. package/src/hub/memory-sync-state.ts +145 -0
  95. package/src/hub/no-writes.ts +1 -1
  96. package/src/hub/operational-settings.ts +11 -2
  97. package/src/hub/operational-view.ts +12 -1
  98. package/src/hub/peers.ts +25 -2
  99. package/src/hub/planning.ts +6 -1
  100. package/src/hub/question-desk.ts +201 -0
  101. package/src/hub/question-detach.ts +6 -0
  102. package/src/hub/questions.ts +260 -0
  103. package/src/hub/research-evidence.ts +106 -0
  104. package/src/hub/restart.ts +14 -0
  105. package/src/hub/review-anchor.ts +181 -0
  106. package/src/hub/review-brief.ts +13 -0
  107. package/src/hub/review-comments.ts +115 -0
  108. package/src/hub/review-findings.ts +7 -2
  109. package/src/hub/review-material.ts +76 -0
  110. package/src/hub/routing.ts +3 -1
  111. package/src/hub/task-sweep.ts +2 -0
  112. package/src/hub/tasks.ts +577 -63
  113. package/src/memory/capture.ts +5 -2
  114. package/src/pet/config.ts +25 -13
  115. package/src/rooms/commands.ts +61 -16
  116. package/src/rooms/link.ts +338 -36
  117. package/src/rooms/liveness.ts +11 -0
  118. package/src/rooms/remote-peer.ts +186 -19
  119. package/src/rooms/service.ts +475 -35
  120. package/src/rooms/status.ts +8 -1
  121. package/src/rooms/store.ts +447 -65
  122. package/src/rooms/wire.ts +24 -6
  123. package/src/ui/index.html +142 -9
  124. package/src/ui/needs.ts +10 -4
  125. package/src/ui/plugin-questions.ts +56 -0
  126. package/src/ui/plugin-results.ts +81 -0
  127. package/src/ui/plugin.ts +207 -27
  128. package/src/pet/window.html +0 -114
  129. package/src/pet/window.jxa.js +0 -122
  130. package/src/pet/window.ts +0 -154
@@ -5,7 +5,7 @@ import { Database } from "bun:sqlite";
5
5
  import { realPath } from "../hub/project.ts";
6
6
  import { MAX_HOP, USER } from "../hub/envelope.ts";
7
7
  import { assertRoomCryptoReady, exportHubIdentityForStorage, generateHubIdentity, importHubIdentity, decodeBase64url, encodeBase64url, sha256, type HubIdentity, type LocalIdentityRecord } from "./crypto.ts";
8
- import { acceptJoin, acceptRoomEpoch, brokerUrl, createRoomEpoch, decodeFrame, decodeInvite, decodeJoin, decodeRoster, positiveU64, roomMembers, type ReplayCommit, type ReplayPort, type SequenceBinding, type SequencePort, type VerifiedEpoch, type RoomMember, type VerifiedPublicRoster, verifyPublicRoster, verifyPublicFrame } from "./wire.ts";
8
+ import { acceptJoin, acceptRoomEpoch, brokerUrl, createRoomEpoch, decodeFrame, decodeInvite, decodeJoin, decodeRoster, openOwnFrame, positiveU64, roomMembers, type ReplayCommit, type ReplayPort, type SequenceBinding, type SequencePort, type VerifiedEpoch, type RoomMember, type VerifiedPublicRoster, verifyPublicRoster, verifyPublicFrame } from "./wire.ts";
9
9
  import { decodeRoomMessage, encodeRoomMessage, type RoomChatEnvelope, type RoomMessage, type RoomReceiptReason, parseRemotePeerId } from "./application.ts";
10
10
 
11
11
  export const ROOM_STORE_LIMITS = Object.freeze({ rooms: 64, invites: 128, pendingJoins: 64, inboxPerRoom: 200, inboxBytes: 16 * 1024 * 1024, receiptsPerRoom: 1000, outboxPerRoom: 200, outboxBytes: 16 * 1024 * 1024, floorsPerRoom: 256, refusedPerRoom: 1000, receiptReservePerRoom: 8, receiptReserveBytes: 1024 });
@@ -28,7 +28,7 @@ export class RoomApplicationLimitError extends RoomStoreError {
28
28
  }
29
29
  }
30
30
  function fail(code: RoomStoreCode): never { throw new RoomStoreError(code); }
31
- const DB_NAME = "rooms.db", SCHEMA = 4, GRACE_MS = 60_000;
31
+ const DB_NAME = "rooms.db", SCHEMA = 5, GRACE_MS = 60_000;
32
32
  const ROOM_DB_BUSY_TIMEOUT_MS = 1;
33
33
  // ponytail: keep at most 256 historical sender floors per room; refuse new senders at the ceiling until versioned archival preserves replay proofs.
34
34
  const NAME = /^[a-z][a-z0-9-]{0,31}$/;
@@ -52,11 +52,16 @@ export const ROOM_PERSON_INBOX_LIMITS = Object.freeze({ unreadPerRoom: 100, body
52
52
  export interface RoomOutgoingInput { deliveryId: string; application: string; remotePeer: string; recipient: string; peer: string; ackTimeoutMs: number; ttlMs: number }
53
53
  export interface RoomOutgoing {
54
54
  digest: string; deliveryId: string; application: string; remotePeer: string; recipient: string; peer: string;
55
- createdAt: number; expiresAt: number; ackDeadline?: number;
55
+ frameDigest?: string;
56
+ createdAt: number; expiresAt: number; ackTimeoutMs: number; ackDeadline?: number;
56
57
  state: "dispatching" | "accepted" | "completed" | "failed_safe" | "needs_review";
57
58
  disposition?: "completed" | "retry" | "discard";
58
59
  reason?: RoomReceiptReason; lateReceipt?: { outcome: "completed" | "failed_safe"; reason: RoomReceiptReason };
59
60
  }
61
+ export interface RoomOutboxEntry { digest: string; wire: Uint8Array; state: "dispatching" | "accepted"; kind: "generic" | "application" | "receipt"; receiptFor?: string; expiresAt?: number }
62
+ export type RoomStoredReceipt =
63
+ | { digest: string; wire: Uint8Array; state: "dispatching"; kind: "receipt"; receiptFor: string; expiresAt: number }
64
+ | { digest: string; state: "accepted"; kind: "receipt"; receiptFor: string; expiresAt: number };
60
65
  type ApplicationState = "pending" | "dispatching" | "completed" | "failed_safe" | "needs_review" | "disposed";
61
66
  interface ApplicationRow { room: string; sender: string; application: string; content_hash: string; primary_digest: string; peer: string; trace: string; hop: number; state: ApplicationState; reason: RoomReceiptReason | null; created_at: number; settled_at: number | null; disposition: "accept" | "discard" | null; disposed_at: number | null; disposed_by: "user" | null }
62
67
  export interface RoomIncomingApplication { sender: string; application: string; peer: string; state: ApplicationState; digest: string; disposition?: "accept" | "discard"; disposedAt?: number; disposedBy?: "user" }
@@ -64,9 +69,10 @@ interface OutgoingRow {
64
69
  room: string; digest: string; delivery_id: string; application: string; remote_peer: string; recipient: string; peer: string;
65
70
  created_at: number; expires_at: number; ack_timeout: number; ack_deadline: number | null; state: RoomOutgoing["state"];
66
71
  reason: RoomReceiptReason | null; late_outcome: "completed" | "failed_safe" | null; late_reason: RoomReceiptReason | null; receipt_digest: string | null;
67
- disposition: "completed" | "retry" | "discard" | null;
72
+ disposition: "completed" | "retry" | "discard" | null; content_hash: string | null;
68
73
  }
69
74
  const APPLICATION_REASONS: readonly RoomReceiptReason[] = ["admitted", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable"];
75
+ const PERMANENT_OUTGOING_REFUSALS: readonly RoomReceiptReason[] = ["not_exposed", "boundary_refused", "invalid_message"];
70
76
  const APPLICATION_HISTORY_CAP = 1000;
71
77
  export const ROOM_APPLICATION_RETENTION = Object.freeze({ identitiesPerRoom: 10_000, aliasesPerRoom: 10_000, settledMs: 30 * 24 * 60 * 60 * 1000 });
72
78
  const isTerminal = (state: string): boolean => state === "completed" || state === "failed_safe";
@@ -81,7 +87,7 @@ function boundedId(value: unknown): value is string { return typeof value === "s
81
87
  function validUuid(value: unknown): value is string { return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value); }
82
88
  function outgoing(row: OutgoingRow): RoomOutgoing {
83
89
  return { digest: row.digest, deliveryId: row.delivery_id, application: row.application, remotePeer: row.remote_peer, recipient: row.recipient, peer: row.peer,
84
- createdAt: row.created_at, expiresAt: row.expires_at, ...(row.ack_deadline !== null ? { ackDeadline: row.ack_deadline } : {}), state: row.state,
90
+ createdAt: row.created_at, expiresAt: row.expires_at, ackTimeoutMs: row.ack_timeout, ...(row.ack_deadline !== null ? { ackDeadline: row.ack_deadline } : {}), state: row.state,
85
91
  ...(row.reason !== null ? { reason: row.reason } : {}), ...(row.disposition !== null ? { disposition: row.disposition } : {}), ...(row.late_outcome !== null ? { lateReceipt: { outcome: row.late_outcome, reason: row.late_reason! } } : {}) };
86
92
  }
87
93
  function incomingMetadata(row: ApplicationRow, digestValue = row.primary_digest): RoomIncomingApplication {
@@ -158,7 +164,7 @@ export class RoomStore {
158
164
  db.run(`PRAGMA busy_timeout = ${ROOM_DB_BUSY_TIMEOUT_MS}`);
159
165
  db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;");
160
166
  const version = db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version;
161
- if (version !== 0 && version !== 1 && version !== 2 && version !== 3 && version !== SCHEMA) fail("unsupported");
167
+ if (version !== 0 && version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== SCHEMA) fail("unsupported");
162
168
  if (version === 0) {
163
169
  if (!created) fail("corrupt");
164
170
  db.exec(`
@@ -175,14 +181,14 @@ export class RoomStore {
175
181
  PRAGMA user_version=1;
176
182
  `);
177
183
  }
178
- const tables = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT 14").all().map(x => x.name).join(",");
184
+ const tables = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT 16").all().map(x => x.name).join(",");
179
185
  const legacyTables = "counters,floors,inbox,invites,meta,outbox,pending_joins,refusals,rooms";
180
- if (tables !== (version === 4 ? "application_frames,application_outbox,applications,counters,floors,inbox,invites,meta,outbox,pending_joins,person_inbox,refusals,rooms" : version! >= 2 ? "application_frames,application_outbox,applications," + legacyTables : legacyTables)) fail("unsupported");
186
+ if (tables !== (version === 5 ? "application_attempts,application_frames,application_outbox,applications,counters,floors,inbox,invites,meta,outbox,pending_joins,person_inbox,receipt_outbox,refusals,rooms" : version === 4 ? "application_frames,application_outbox,applications,counters,floors,inbox,invites,meta,outbox,pending_joins,person_inbox,refusals,rooms" : version! >= 2 ? "application_frames,application_outbox,applications," + legacyTables : legacyTables)) fail("unsupported");
181
187
  const columns: Record<string, string[]> = {
182
188
  meta: ["key", "value"],
183
189
  rooms: ["name", "room", "broker", "owner", "epoch", "digest", "wire", "previous_wire", "previous_digest", "previous_epoch", "previous_expires", "left"],
184
190
  counters: ["room", "seq"], floors: ["room", "sender", "seq", "digest"],
185
- inbox: ["room", "sender", "seq", "digest", "plaintext", "gap", ...(version! >= 3 ? ["receipt_reserve"] : []), ...(version === 4 ? ["received_at"] : [])],
191
+ inbox: ["room", "sender", "seq", "digest", "plaintext", "gap", ...(version! >= 3 ? ["receipt_reserve"] : []), ...(version! >= 4 ? ["received_at"] : [])],
186
192
  invites: ["name", "id", "wire", "digest", "expires", "request_digest"],
187
193
  outbox: ["room", "digest", "wire", "roster", "state"], pending_joins: ["name", "invite", "request", "selection"], refusals: ["room", "digest", "reason", "at"],
188
194
  };
@@ -202,7 +208,7 @@ export class RoomStore {
202
208
  PRAGMA user_version=2;
203
209
  `);
204
210
  const applicationColumns: Record<string, string[]> = {
205
- application_outbox: ["room", "digest", "delivery_id", "application", "remote_peer", "recipient", "peer", "created_at", "expires_at", "ack_timeout", "ack_deadline", "state", "reason", "late_outcome", "late_reason", "receipt_digest", "disposition"],
211
+ application_outbox: ["room", "digest", "delivery_id", "application", "remote_peer", "recipient", "peer", "created_at", "expires_at", "ack_timeout", "ack_deadline", "state", "reason", "late_outcome", "late_reason", "receipt_digest", "disposition", ...(version === 5 ? ["content_hash"] : [])],
206
212
  applications: ["room", "sender", "application", "content_hash", "primary_digest", "peer", "trace", "hop", "state", "reason", "created_at", "settled_at", "disposition", "disposed_at", "disposed_by"],
207
213
  application_frames: ["room", "digest", "sender", "application"],
208
214
  };
@@ -227,6 +233,26 @@ export class RoomStore {
227
233
  CREATE INDEX person_inbox_retention ON person_inbox(state,settled_at);
228
234
  PRAGMA user_version=4;
229
235
  `);
236
+ if (version! < 5) db.exec(`
237
+ UPDATE application_outbox SET ack_deadline=MIN(ack_deadline,expires_at);
238
+ ALTER TABLE application_outbox ADD COLUMN content_hash TEXT;
239
+ CREATE TABLE application_attempts (room TEXT NOT NULL, digest TEXT NOT NULL, delivery_id TEXT NOT NULL, application TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(room,digest)) WITHOUT ROWID;
240
+ CREATE INDEX application_attempts_delivery ON application_attempts(room,delivery_id);
241
+ CREATE TABLE receipt_outbox (room TEXT NOT NULL, inbound_digest TEXT NOT NULL, receipt_digest TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL, recipient TEXT NOT NULL, epoch TEXT NOT NULL, roster_digest TEXT NOT NULL, PRIMARY KEY(room,inbound_digest,receipt_digest)) WITHOUT ROWID;
242
+ CREATE UNIQUE INDEX receipt_outbox_frame ON receipt_outbox(room,receipt_digest);
243
+ INSERT INTO application_attempts(room,digest,delivery_id,application,created_at) SELECT room,digest,delivery_id,application,created_at FROM application_outbox;
244
+ PRAGMA user_version=5;
245
+ `);
246
+ const attemptColumns = ["room", "digest", "delivery_id", "application", "created_at"];
247
+ const outgoingColumns = ["room", "digest", "delivery_id", "application", "remote_peer", "recipient", "peer", "created_at", "expires_at", "ack_timeout", "ack_deadline", "state", "reason", "late_outcome", "late_reason", "receipt_digest", "disposition", "content_hash"];
248
+ if (db.query<{ name: string }, []>("PRAGMA table_info(application_outbox)").all().map(row => row.name).join(",") !== outgoingColumns.join(",")) fail("unsupported");
249
+ for (const table of ["application_attempts", "receipt_outbox"]) {
250
+ if (db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all().map(row => row.name).join(",") !== (table === "application_attempts" ? attemptColumns.join(",") : "room,inbound_digest,receipt_digest,created_at,expires_at,state,recipient,epoch,roster_digest")) fail("unsupported");
251
+ }
252
+ for (const [index, fields] of Object.entries({ application_attempts_delivery: ["room", "delivery_id"], receipt_outbox_frame: ["room", "receipt_digest"] })) {
253
+ if (db.query<{ name: string }, []>(`PRAGMA index_info(${index})`).all().map(row => row.name).join(",") !== fields.join(",")) fail("unsupported");
254
+ }
255
+ if (db.query<{ name: string; unique: number }, []>("PRAGMA index_list(receipt_outbox)").all().find(row => row.name === "receipt_outbox_frame")?.unique !== 1) fail("unsupported");
230
256
  const personColumns = ["id", "room", "sender", "application", "primary_digest", "from_peer", "received_at", "expires_at", "bytes", "body", "state", "settled_at"];
231
257
  if (db.query<{ name: string }, []>("PRAGMA table_info(person_inbox)").all().map(row => row.name).join(",") !== personColumns.join(",")) fail("unsupported");
232
258
  for (const [index, fields] of Object.entries({ person_inbox_room: ["room", "state", "received_at", "id"], person_inbox_retention: ["state", "settled_at"], person_inbox_identity: ["room", "sender", "application"] })) {
@@ -288,6 +314,8 @@ export class RoomStore {
288
314
  cap("counters", limits.rooms); cap("floors", limits.rooms * limits.floorsPerRoom);
289
315
  cap("inbox", limits.rooms * (limits.receiptsPerRoom + limits.inboxPerRoom + limits.receiptReservePerRoom));
290
316
  cap("outbox", limits.rooms * limits.outboxPerRoom); cap("invites", limits.invites); cap("pending_joins", limits.pendingJoins); cap("refusals", limits.rooms * limits.refusedPerRoom);
317
+ cap("application_attempts", limits.rooms * ROOM_APPLICATION_RETENTION.aliasesPerRoom);
318
+ cap("receipt_outbox", limits.rooms * ROOM_APPLICATION_RETENTION.aliasesPerRoom);
291
319
  const groupCap = (table: string, filter: string, maximum: number) => {
292
320
  if (this.db.query<{ room: string }, [number]>(`SELECT room FROM ${table} ${filter} GROUP BY room HAVING COUNT(*)>? LIMIT 1`).get(maximum)) fail("corrupt");
293
321
  };
@@ -297,12 +325,16 @@ export class RoomStore {
297
325
  groupCap("inbox", "WHERE plaintext IS NULL", limits.receiptsPerRoom);
298
326
  groupCap("outbox", "", limits.outboxPerRoom);
299
327
  groupCap("refusals", "", limits.refusedPerRoom);
328
+ groupCap("application_attempts", "", ROOM_APPLICATION_RETENTION.aliasesPerRoom);
329
+ groupCap("receipt_outbox", "", ROOM_APPLICATION_RETENTION.aliasesPerRoom);
300
330
  const metadataBounds: Record<string, string> = {
301
331
  counters: "length(room)>43 OR length(seq)>20",
302
332
  floors: "length(room)>43 OR length(sender)>64 OR length(seq)>20 OR length(digest)>43",
303
333
  inbox: "length(room)>43 OR length(sender)>64 OR length(seq)>20 OR length(digest)>43 OR length(gap)>20",
304
334
  invites: "length(name)>32 OR length(id)>43 OR length(digest)>43 OR length(request_digest)>43",
305
335
  outbox: "length(room)>43 OR length(digest)>43 OR length(state)>16",
336
+ application_attempts: "length(room)>43 OR length(digest)>43 OR length(delivery_id)>256 OR length(application)>256",
337
+ receipt_outbox: "length(room)>43 OR length(inbound_digest)>43 OR length(receipt_digest)>43 OR length(state)>16 OR length(recipient)>64 OR length(epoch)>20 OR length(roster_digest)>43",
306
338
  pending_joins: "length(name)>32",
307
339
  refusals: "length(room)>43 OR length(digest)>43 OR length(reason)>32",
308
340
  };
@@ -389,6 +421,7 @@ export class RoomStore {
389
421
  if (!validSelection(row.selection)) fail("corrupt");
390
422
  }
391
423
  await this.validateApplications();
424
+ await this.validateReceiptOutbox();
392
425
  await this.validatePersonInbox();
393
426
  }
394
427
 
@@ -404,7 +437,7 @@ export class RoomStore {
404
437
  }
405
438
  if (this.db.query("SELECT room FROM application_outbox WHERE state IN ('completed','failed_safe') OR disposition IS NOT NULL GROUP BY room HAVING COUNT(*)>1000 LIMIT 1").get()
406
439
  || this.db.query("SELECT room FROM applications WHERE state IN ('pending','dispatching') GROUP BY room HAVING COUNT(*)>200 LIMIT 1").get()
407
- || this.db.query("SELECT 1 FROM application_outbox WHERE length(room)>43 OR length(digest)>43 OR length(delivery_id)>256 OR length(application)>256 OR length(remote_peer)>65 OR length(recipient)>64 OR length(peer)>32 OR length(state)>16 OR length(reason)>32 OR length(late_outcome)>16 OR length(late_reason)>32 OR length(receipt_digest)>43 OR length(disposition)>16 LIMIT 1").get()
440
+ || this.db.query("SELECT 1 FROM application_outbox WHERE length(room)>43 OR length(digest)>43 OR length(delivery_id)>256 OR length(application)>256 OR length(remote_peer)>65 OR length(recipient)>64 OR length(peer)>32 OR length(state)>16 OR length(reason)>32 OR length(late_outcome)>16 OR length(late_reason)>32 OR length(receipt_digest)>43 OR length(disposition)>16 OR length(content_hash)>43 LIMIT 1").get()
408
441
  || this.db.query("SELECT 1 FROM applications WHERE length(room)>43 OR length(sender)>64 OR length(application)>256 OR length(content_hash)>43 OR length(primary_digest)>43 OR length(peer)>32 OR length(trace)>36 OR length(state)>16 OR length(reason)>32 OR length(disposition)>8 OR length(disposed_by)>4 LIMIT 1").get()
409
442
  || this.db.query("SELECT 1 FROM application_frames WHERE length(room)>43 OR length(digest)>43 OR length(sender)>64 OR length(application)>256 LIMIT 1").get()) fail("corrupt");
410
443
  const applications = new Map<string, ApplicationRow>();
@@ -444,18 +477,69 @@ export class RoomStore {
444
477
  || !["dispatching", "accepted", "completed", "failed_safe", "needs_review"].includes(row.state)
445
478
  || !Number.isSafeInteger(row.created_at) || row.created_at <= 0 || row.created_at > now || !Number.isSafeInteger(row.expires_at)
446
479
  || row.expires_at <= row.created_at || row.expires_at - row.created_at > 604_800_000
447
- || !Number.isSafeInteger(row.ack_timeout) || row.ack_timeout < 1 || row.ack_timeout > 86_400_000) fail("corrupt");
448
- if (!Number.isSafeInteger(row.ack_deadline) || row.ack_deadline! < row.created_at || row.ack_deadline! > row.created_at + row.ack_timeout) fail("corrupt");
449
- if (isTerminal(row.state) ? !validOutcome(row.state, row.reason, row.peer) : row.reason !== null) fail("corrupt");
450
- if (row.disposition !== null && (row.state !== "needs_review" || !["completed", "retry", "discard"].includes(row.disposition))) fail("corrupt");
451
- if (row.late_outcome !== null ? row.state !== "needs_review" || !validOutcome(row.late_outcome, row.late_reason, row.peer) : row.late_reason !== null) fail("corrupt");
480
+ || !Number.isSafeInteger(row.ack_timeout) || row.ack_timeout < 1 || row.ack_timeout > 86_400_000
481
+ || row.content_hash !== null && !validDigest(row.content_hash)) fail("corrupt");
482
+ if (!Number.isSafeInteger(row.ack_deadline) || row.ack_deadline! < row.created_at || row.ack_deadline! > row.expires_at) fail("corrupt");
483
+ if (isTerminal(row.state) ? !validOutcome(row.state, row.reason, row.peer)
484
+ : row.state === "needs_review" && row.reason !== null ? !PERMANENT_OUTGOING_REFUSALS.includes(row.reason)
485
+ : row.reason !== null) fail("corrupt");
486
+ if (row.disposition !== null && (row.state !== "needs_review" && !isTerminal(row.state) || !["completed", "retry", "discard"].includes(row.disposition))) fail("corrupt");
487
+ if (row.late_outcome !== null
488
+ ? !validOutcome(row.late_outcome, row.late_reason, row.peer)
489
+ || row.state !== "needs_review" && (row.state !== row.late_outcome || row.reason !== row.late_reason)
490
+ : row.late_reason !== null) fail("corrupt");
452
491
  if ((isTerminal(row.state) || row.late_outcome !== null) ? !validDigest(row.receipt_digest) : row.receipt_digest !== null) fail("corrupt");
453
- const sealed = this.db.query<{ wire: Uint8Array; room: string }, [string]>("SELECT wire,room FROM outbox WHERE digest=?").get(row.digest);
454
- if (isTerminal(row.state) || row.disposition !== null) { if (sealed) fail("corrupt"); }
455
- else {
456
- if (!sealed || sealed.room !== row.room) fail("corrupt");
492
+ if (row.late_outcome !== null && this.lateReceiptProof(row) === "invalid") fail("corrupt");
493
+ const attempts = this.db.query<{ digest: string }, [string, string]>("SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?").all(row.room, row.delivery_id);
494
+ if (!attempts.some(attempt => attempt.digest === row.digest)) fail("corrupt");
495
+ const sealedAttempts = this.db.query<{ wire: Uint8Array; room: string }, [string, string]>("SELECT o.wire,o.room FROM outbox o JOIN application_attempts a ON a.room=o.room AND a.digest=o.digest WHERE a.room=? AND a.delivery_id=?").all(row.room, row.delivery_id);
496
+ if (isTerminal(row.state) || row.disposition !== null) { if (sealedAttempts.length) fail("corrupt"); }
497
+ else if ((row.state === "dispatching" || row.state === "accepted" || row.state === "needs_review" && row.reason === null && row.disposition === null && row.late_outcome === null && row.expires_at > now) && !sealedAttempts.length) fail("corrupt");
498
+ if (sealedAttempts.some(sealed => {
457
499
  const frame = decodeFrame(sealed.wire);
458
- if (frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient) fail("corrupt");
500
+ return sealed.room !== row.room || frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient;
501
+ })) fail("corrupt");
502
+ }
503
+ const outgoingRows = new Map<string, OutgoingRow>();
504
+ for (const row of this.db.query<OutgoingRow, []>("SELECT * FROM application_outbox").all()) outgoingRows.set(`${row.room}|${row.delivery_id}`, row);
505
+ for (const row of this.db.query<{ room: string; digest: string; delivery_id: string; application: string; created_at: number }, []>("SELECT * FROM application_attempts").all()) {
506
+ const saved = outgoingRows.get(`${row.room}|${row.delivery_id}`);
507
+ if (!saved || saved.application !== row.application || !validDigest(row.digest) || !Number.isSafeInteger(row.created_at) || row.created_at < saved.created_at || row.created_at > now) fail("corrupt");
508
+ }
509
+ }
510
+
511
+ private lateReceiptProof(row: OutgoingRow): "missing" | "valid" | "invalid" {
512
+ if (!row.receipt_digest || !row.late_outcome) return "missing";
513
+ const entry = this.db.query<{ sender: string; plaintext: Uint8Array | null }, [string, string]>("SELECT sender,plaintext FROM inbox WHERE room=? AND digest=?").get(row.room, row.receipt_digest);
514
+ if (!entry) return "missing";
515
+ if (entry.sender !== row.recipient) return "invalid";
516
+ if (entry.plaintext === null) return "valid";
517
+ let message: RoomMessage;
518
+ try { message = decodeRoomMessage(entry.plaintext); } catch { return "invalid"; }
519
+ if (message.type !== "receipt" || message.application !== row.application || message.peer !== row.peer
520
+ || message.outcome !== row.late_outcome || message.reason !== row.late_reason) return "invalid";
521
+ const attempt = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(row.room, message.frame);
522
+ return attempt?.delivery_id === row.delivery_id ? "valid" : "invalid";
523
+ }
524
+
525
+ private async validateReceiptOutbox(): Promise<void> {
526
+ const maximum = ROOM_STORE_LIMITS.rooms * ROOM_APPLICATION_RETENTION.aliasesPerRoom;
527
+ if (this.db.query("SELECT room,inbound_digest FROM receipt_outbox GROUP BY room,inbound_digest HAVING SUM(state='dispatching')>1 LIMIT 1").get()) fail("corrupt");
528
+ for (const row of this.db.query<{ room: string; inbound_digest: string; receipt_digest: string; created_at: number; expires_at: number; state: string; recipient: string; epoch: string; roster_digest: string }, []>(`SELECT * FROM receipt_outbox LIMIT ${maximum + 1}`).all()) {
529
+ if (!this.byRoom(row.room) || !validDigest(row.inbound_digest) || !validDigest(row.receipt_digest) || !Number.isSafeInteger(row.created_at) || row.created_at <= 0 || row.created_at > this.now()
530
+ || !Number.isSafeInteger(row.expires_at) || row.expires_at <= row.created_at || row.expires_at - row.created_at > 604_800_000 || !["dispatching", "accepted"].includes(row.state)
531
+ || !/^[a-f0-9]{64}$/.test(row.recipient) || positiveU64(row.epoch) !== row.epoch || !validDigest(row.roster_digest)) fail("corrupt");
532
+ const incoming = this.applicationFor(row.room, row.inbound_digest);
533
+ const outgoing = this.db.query<{ wire: Uint8Array; roster: Uint8Array; room: string; state: string }, [string, string]>("SELECT wire,roster,room,state FROM outbox WHERE room=? AND digest=?").get(row.room, row.receipt_digest);
534
+ if (!incoming || !isTerminal(incoming.state) || incoming.sender !== row.recipient) fail("corrupt");
535
+ if (row.state === "dispatching" && (!outgoing || outgoing.room !== row.room || outgoing.state !== "dispatching")) fail("corrupt");
536
+ if (row.state === "accepted" && outgoing && (outgoing.room !== row.room || outgoing.state !== "accepted")) fail("corrupt");
537
+ if (outgoing) {
538
+ const installed = this.byRoom(row.room)!, owner = decodeRoster(installed.wire).owner;
539
+ const roster = await verifyPublicRoster({ wire: outgoing.roster, pin: { room: row.room, owner, minEpoch: "0" } });
540
+ const frame = await verifyPublicFrame({ wire: outgoing.wire, roster });
541
+ if (frame.digest !== row.receipt_digest || frame.room !== row.room || frame.epoch !== row.epoch || frame.rosterDigest !== row.roster_digest
542
+ || frame.sender !== this.identity.public.fingerprint || frame.recipients.length !== 1 || frame.recipients[0] !== incoming.sender) fail("corrupt");
459
543
  }
460
544
  }
461
545
  }
@@ -589,6 +673,12 @@ export class RoomStore {
589
673
  ON CONFLICT(name) DO UPDATE SET epoch=excluded.epoch,digest=excluded.digest,wire=excluded.wire,previous_wire=excluded.previous_wire,previous_digest=excluded.previous_digest,previous_epoch=excluded.previous_epoch,previous_expires=excluded.previous_expires,left=0`)
590
674
  .run(name, current.room, broker, decodeRoster(wire).owner, current.epoch, current.digest, wire, prior ? priorWire! : null, prior?.epoch.digest ?? null, prior?.epoch.epoch ?? null, prior?.expiresAt ?? null);
591
675
  this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
676
+ if (previous && !same) {
677
+ // Epoch adoption and the old sealed intents' send fence commit together.
678
+ // The local Bus names the rotation; no new signed receipt reason is introduced.
679
+ this.db.query("UPDATE application_outbox SET state='needs_review',reason='boundary_refused' WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL").run(current.room);
680
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT t.digest FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=? AND a.state='needs_review' AND a.reason='boundary_refused')").run(current.room, current.room);
681
+ }
592
682
  return next;
593
683
  }
594
684
  async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
@@ -735,6 +825,32 @@ export class RoomStore {
735
825
  const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
736
826
  const frame = await verifyPublicFrame({ wire, roster: publicRoster });
737
827
  if (frame.sender !== this.identity.public.fingerprint) fail("untrusted");
828
+ let contentHash: string | undefined;
829
+ let legacyContentHash: string | undefined;
830
+ let legacyDeliveryId: string | undefined;
831
+ let legacyAttemptDigest: string | undefined;
832
+ if (application) {
833
+ const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
834
+ let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("invalid"); }
835
+ if (message.type !== "chat" || message.envelope.id !== application.application || message.envelope.to !== application.peer) fail("state_changed");
836
+ contentHash = await digest(encodeRoomMessage(message));
837
+ const saved = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND application=? ORDER BY rowid").all(frame.room, application.application);
838
+ const legacy = saved.filter(row => row.content_hash === null);
839
+ if (legacy.length) {
840
+ if (saved.length !== 1 || legacy[0]!.delivery_id !== application.deliveryId
841
+ || legacy[0]!.state !== "dispatching" && legacy[0]!.state !== "accepted" || legacy[0]!.disposition !== null
842
+ || legacy[0]!.reason !== null || legacy[0]!.expires_at <= this.now()) fail("state_changed");
843
+ legacyDeliveryId = legacy[0]!.delivery_id;
844
+ legacyAttemptDigest = this.db.query<{ digest: string }, [string, string]>("SELECT t.digest FROM application_attempts t JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.delivery_id=? ORDER BY t.created_at DESC,o.rowid DESC LIMIT 1").get(frame.room, legacyDeliveryId)?.digest;
845
+ if (!legacyAttemptDigest) fail("state_changed");
846
+ const previous = await this.outgoingMessage(name, application.application, current);
847
+ if (!previous || previous.type !== "chat") fail("state_changed");
848
+ legacyContentHash = await digest(encodeRoomMessage(previous));
849
+ if (legacyContentHash !== contentHash) fail("state_changed");
850
+ const afterReadDigest = this.db.query<{ digest: string }, [string, string]>("SELECT t.digest FROM application_attempts t JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.delivery_id=? ORDER BY t.created_at DESC,o.rowid DESC LIMIT 1").get(frame.room, legacyDeliveryId)?.digest;
851
+ if (afterReadDigest !== legacyAttemptDigest) fail("state_changed");
852
+ }
853
+ }
738
854
  return this.transaction(now => {
739
855
  assertCurrent(current);
740
856
  if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
@@ -743,69 +859,313 @@ export class RoomStore {
743
859
  if (!recipient || application.recipient === this.identity.public.fingerprint || frame.recipients.length !== 1
744
860
  || frame.recipients[0] !== application.recipient || application.remotePeer !== `${application.peer}@${recipient.label}`) fail("untrusted");
745
861
  }
862
+ const applicationRows = application ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND application=? ORDER BY created_at,rowid").all(frame.room, application.application) : [];
863
+ if (application) {
864
+ if (contentHash === undefined) fail("corrupt");
865
+ for (const prior of applicationRows) {
866
+ if (prior.remote_peer !== application.remotePeer || prior.recipient !== application.recipient || prior.peer !== application.peer
867
+ || prior.ack_timeout !== application.ackTimeoutMs || prior.expires_at - prior.created_at !== application.ttlMs
868
+ || prior.expires_at <= now || prior.disposition !== null || prior.state === "needs_review"
869
+ || prior.reason !== null && PERMANENT_OUTGOING_REFUSALS.includes(prior.reason)) fail("state_changed");
870
+ if (prior.content_hash !== null && prior.content_hash !== contentHash) fail("state_changed");
871
+ if (prior.content_hash === null && (legacyContentHash !== contentHash || prior.delivery_id !== legacyDeliveryId)) fail("state_changed");
872
+ }
873
+ if (legacyContentHash === contentHash && legacyDeliveryId) {
874
+ const attempt = this.db.query<{ digest: string }, [string, string]>("SELECT t.digest FROM application_attempts t JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.delivery_id=? ORDER BY t.created_at DESC,o.rowid DESC LIMIT 1").get(frame.room, legacyDeliveryId);
875
+ if (attempt?.digest !== legacyAttemptDigest) fail("state_changed");
876
+ const update = this.db.query("UPDATE application_outbox SET content_hash=? WHERE room=? AND delivery_id=? AND content_hash IS NULL").run(contentHash, frame.room, legacyDeliveryId);
877
+ if (update.changes !== 1) {
878
+ const currentHash = this.db.query<{ content_hash: string | null }, [string, string]>("SELECT content_hash FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, legacyDeliveryId)?.content_hash;
879
+ if (currentHash !== contentHash) fail("state_changed");
880
+ }
881
+ }
882
+ }
746
883
  const existing = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(frame.digest);
747
884
  const saved = this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest);
748
885
  if (existing || saved) {
886
+ if (!existing) fail("state_changed");
749
887
  if (existing && Buffer.compare(Buffer.from(wire), Buffer.from(existing.wire))) fail("state_changed");
750
888
  if (!application) { if (saved) fail("state_changed"); return undefined; }
751
- if (!saved || saved.delivery_id !== application.deliveryId || saved.application !== application.application
752
- || saved.remote_peer !== application.remotePeer || saved.recipient !== application.recipient || saved.peer !== application.peer
753
- || saved.ack_timeout !== application.ackTimeoutMs || saved.expires_at - saved.created_at !== application.ttlMs) fail("state_changed");
754
- return outgoing(saved);
889
+ const alias = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(frame.room, frame.digest);
890
+ const row = saved ?? (alias ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, alias.delivery_id) : undefined);
891
+ if (!row || row.delivery_id !== application.deliveryId || row.application !== application.application
892
+ || row.remote_peer !== application.remotePeer || row.recipient !== application.recipient || row.peer !== application.peer
893
+ || row.ack_timeout !== application.ackTimeoutMs || row.expires_at - row.created_at !== application.ttlMs || row.content_hash !== contentHash) fail("state_changed");
894
+ return { ...outgoing(row), frameDigest: frame.digest };
755
895
  }
756
- if (application && this.db.query("SELECT 1 FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId)) fail("state_changed");
896
+ const row = application ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId) : undefined;
897
+ if (application && this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(frame.room, frame.digest)) fail("state_changed");
898
+ if (row && (row.application !== application!.application || row.remote_peer !== application!.remotePeer || row.recipient !== application!.recipient || row.peer !== application!.peer
899
+ || row.ack_timeout !== application!.ackTimeoutMs || row.expires_at - row.created_at !== application!.ttlMs || row.disposition !== null
900
+ || row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at || row.content_hash !== contentHash)) fail("state_changed");
901
+ const aliasCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_attempts WHERE room=?").get(frame.room)?.n ?? 0;
902
+ if (application && aliasCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) fail("limit");
903
+ if (row) this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(frame.room, frame.room, row.delivery_id);
757
904
  const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(frame.room)?.n ?? 0;
758
905
  const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
759
906
  if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) fail("limit");
760
907
  this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
761
908
  if (!application) return undefined;
762
- const expires = now + application.ttlMs, deadline = now + application.ackTimeoutMs;
909
+ if (row) {
910
+ const deadline = Math.min(now + application.ackTimeoutMs, row.expires_at);
911
+ if (!Number.isSafeInteger(deadline)) fail("invalid");
912
+ this.db.query("INSERT INTO application_attempts(room,digest,delivery_id,application,created_at) VALUES(?,?,?,?,?)").run(frame.room, frame.digest, row.delivery_id, row.application, now);
913
+ this.db.query("UPDATE application_outbox SET ack_deadline=?,state='dispatching' WHERE room=? AND delivery_id=?").run(deadline, frame.room, row.delivery_id);
914
+ const updated = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, row.delivery_id)!;
915
+ return { ...outgoing(updated), frameDigest: frame.digest };
916
+ }
917
+ const canonical = applicationRows[0];
918
+ const expires = canonical?.expires_at ?? now + application.ttlMs, deadline = Math.min(now + application.ackTimeoutMs, expires);
763
919
  if (!Number.isSafeInteger(expires) || !Number.isSafeInteger(deadline)) fail("invalid");
764
920
  this.db.query("INSERT INTO application_outbox(room,digest,delivery_id,application,remote_peer,recipient,peer,created_at,expires_at,ack_timeout,ack_deadline,state) VALUES(?,?,?,?,?,?,?,?,?,?,?,'dispatching')")
765
921
  .run(frame.room, frame.digest, application.deliveryId, application.application, application.remotePeer, application.recipient, application.peer, now, expires, application.ackTimeoutMs, deadline);
766
- return outgoing(this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest)!);
922
+ this.db.query("UPDATE application_outbox SET created_at=?,content_hash=? WHERE room=? AND delivery_id=?").run(canonical?.created_at ?? now, contentHash!, frame.room, application.deliveryId);
923
+ this.db.query("INSERT INTO application_attempts(room,digest,delivery_id,application,created_at) VALUES(?,?,?,?,?)").run(frame.room, frame.digest, application.deliveryId, application.application, now);
924
+ return { ...outgoing(this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest)!), frameDigest: frame.digest };
767
925
  });
768
926
  }
769
- outbox(name: string): { digest: string; wire: Uint8Array; state: "dispatching" | "accepted" }[] {
927
+ outbox(name: string): RoomOutboxEntry[] {
770
928
  const room = this.get(name); if (!room || room.left) return [];
771
- return this.db.query<{ digest: string; wire: Uint8Array; state: "dispatching" | "accepted" }, [string]>("SELECT o.digest,o.wire,o.state FROM outbox o LEFT JOIN application_outbox a ON a.digest=o.digest WHERE o.room=? AND (a.digest IS NULL OR a.state IN ('dispatching','accepted')) ORDER BY o.rowid").all(room.current.room).map(row => ({ ...row, wire: copy(row.wire) }));
929
+ const now = this.now();
930
+ return this.db.query<{ digest: string; wire: Uint8Array; state: "dispatching" | "accepted"; kind: RoomOutboxEntry["kind"]; receiptFor: string | null; expiresAt: number | null }, [string, number]>("SELECT o.digest,o.wire,o.state,CASE WHEN r.receipt_digest IS NOT NULL THEN 'receipt' WHEN a.digest IS NOT NULL THEN 'application' ELSE 'generic' END AS kind,r.inbound_digest AS receiptFor,COALESCE(r.expires_at,a.expires_at) AS expiresAt FROM outbox o LEFT JOIN application_attempts t ON t.room=o.room AND t.digest=o.digest LEFT JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id LEFT JOIN receipt_outbox r ON r.room=o.room AND r.receipt_digest=o.digest WHERE o.room=? AND (t.digest IS NULL OR a.state IN ('dispatching','accepted')) AND (r.receipt_digest IS NULL OR r.state='dispatching' AND r.expires_at>?) ORDER BY CASE WHEN r.receipt_digest IS NOT NULL THEN 0 ELSE 1 END,o.rowid").all(room.current.room, now).map(row => ({ digest: row.digest, wire: copy(row.wire), state: row.state, kind: row.kind, ...(row.receiptFor ? { receiptFor: row.receiptFor } : {}), ...(row.expiresAt !== null ? { expiresAt: row.expiresAt } : {}) }))
931
+ .filter(row => row.kind !== "receipt" || this.receiptSendable(name, row.digest));
772
932
  }
773
- markStored(name: string, digestValue: string): void {
933
+ markStored(name: string, digestValue: string, seq?: string): void {
774
934
  const room = this.get(name); if (!room || room.left) fail("untrusted");
775
- if (this.db.query("SELECT 1 FROM application_outbox WHERE digest=?").get(digestValue)) fail("invalid");
776
- this.transaction(() => { this.db.query("UPDATE outbox SET state='accepted' WHERE room=? AND digest=?").run(room.current.room, digestValue); });
935
+ if (this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(room.current.room, digestValue)) fail("invalid");
936
+ this.transaction(now => {
937
+ if (seq !== undefined) {
938
+ if (!/^[1-9][0-9]{0,19}$/.test(seq)) fail("invalid");
939
+ const sealed = this.db.query<{ wire: Uint8Array }, [string, string]>("SELECT wire FROM outbox WHERE room=? AND digest=?").get(room.current.room, digestValue);
940
+ if (!sealed || decodeFrame(sealed.wire).seq !== seq) return;
941
+ }
942
+ const receipt = this.db.query<{ expires_at: number }, [string, string]>("SELECT expires_at FROM receipt_outbox WHERE room=? AND receipt_digest=?").get(room.current.room, digestValue);
943
+ if (receipt && receipt.expires_at <= now) return;
944
+ this.db.query("UPDATE outbox SET state='accepted' WHERE room=? AND digest=?").run(room.current.room, digestValue);
945
+ if (receipt) {
946
+ this.db.query("UPDATE receipt_outbox SET state='accepted' WHERE room=? AND receipt_digest=?").run(room.current.room, digestValue);
947
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(room.current.room, digestValue);
948
+ }
949
+ });
950
+ }
951
+ receiptStored(name: string, inboundDigest: string): RoomStoredReceipt | undefined {
952
+ const state = this.get(name); if (!state || state.left) return undefined;
953
+ if (!validDigest(inboundDigest)) fail("invalid");
954
+ const row = this.db.query<{ receipt_digest: string; expires_at: number; state: "dispatching" | "accepted"; wire: Uint8Array | null }, [string, string]>("SELECT r.receipt_digest,r.expires_at,r.state,o.wire FROM receipt_outbox r LEFT JOIN outbox o ON o.room=r.room AND o.digest=r.receipt_digest WHERE r.room=? AND r.inbound_digest=? ORDER BY CASE WHEN r.state='accepted' THEN 0 ELSE 1 END,r.created_at DESC LIMIT 1").get(state.current.room, inboundDigest);
955
+ if (!row) return undefined;
956
+ if (row.state === "dispatching") {
957
+ if (!row.wire) fail("corrupt");
958
+ return { digest: row.receipt_digest, wire: copy(row.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
959
+ }
960
+ return { digest: row.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
961
+ }
962
+ receiptSendable(name: string, receiptDigest: string): boolean {
963
+ const state = this.get(name); if (!state || state.left || !validDigest(receiptDigest)) return false;
964
+ const row = this.db.query<{ wire: Uint8Array; expires_at: number; epoch: string; roster_digest: string }, [string, string, number]>("SELECT o.wire,r.expires_at,r.epoch,r.roster_digest FROM receipt_outbox r JOIN outbox o ON o.room=r.room AND o.digest=r.receipt_digest WHERE r.room=? AND r.receipt_digest=? AND r.state='dispatching' AND r.expires_at>? AND o.state='dispatching'")
965
+ .get(state.current.room, receiptDigest, this.now());
966
+ if (!row) return false;
967
+ const frame = decodeFrame(row.wire), recipient = frame.recipients[0], now = this.now();
968
+ const members = roomMembers(state.current);
969
+ const currentMember = recipient && members.some(member => member.fingerprint === recipient);
970
+ const senderCurrent = members.some(member => member.fingerprint === this.identity.public.fingerprint);
971
+ const currentEpoch = row.epoch === state.current.epoch && row.roster_digest === state.current.digest;
972
+ const previousEpoch = state.previous && now < state.previous.expiresAt && row.epoch === state.previous.epoch.epoch && row.roster_digest === state.previous.epoch.digest;
973
+ return row.epoch === frame.epoch && validDigest(row.roster_digest) && frame.room === state.current.room && frame.sender === this.identity.public.fingerprint
974
+ && frame.recipients.length === 1 && currentMember === true && senderCurrent && (currentEpoch || previousEpoch === true);
975
+ }
976
+ async queueReceipt(name: string, inboundDigest: string, wire: Uint8Array, options: { ttlMs: number; replaceExisting?: boolean }, current?: () => boolean): Promise<RoomStoredReceipt> {
977
+ if (!validDigest(inboundDigest) || !options || !Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1 || options.ttlMs > 604_800_000) fail("invalid");
978
+ wire = copy(wire);
979
+ const state = this.get(name); if (!state || state.left) fail("untrusted");
980
+ const roster = decodeRoster(state.wire);
981
+ const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
982
+ const frame = await verifyPublicFrame({ wire, roster: publicRoster });
983
+ if (frame.sender !== this.identity.public.fingerprint || frame.recipients.length !== 1) fail("untrusted");
984
+ const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
985
+ let decoded: RoomMessage; try { decoded = decodeRoomMessage(plaintext); } catch { fail("untrusted"); }
986
+ const expected = this.applicationReceipt(name, inboundDigest);
987
+ if (!expected || Buffer.compare(Buffer.from(encodeRoomMessage(decoded)), Buffer.from(encodeRoomMessage(expected))) !== 0) fail("untrusted");
988
+ assertCurrent(current);
989
+ if (this.get(name)?.current.digest !== state.current.digest) fail("state_changed");
990
+ return this.transaction(now => {
991
+ assertCurrent(current);
992
+ if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
993
+ const incoming = this.applicationFor(state.current.room, inboundDigest);
994
+ if (!incoming || !isTerminal(incoming.state) || incoming.sender !== frame.recipients[0]) fail("untrusted");
995
+ this.db.query("DELETE FROM outbox WHERE EXISTS (SELECT 1 FROM receipt_outbox r WHERE r.room=outbox.room AND r.receipt_digest=outbox.digest AND r.expires_at<=?)").run(now);
996
+ this.db.query("DELETE FROM receipt_outbox WHERE expires_at<=?").run(now);
997
+ const rows = this.db.query<{ receipt_digest: string; expires_at: number; state: "dispatching" | "accepted"; wire: Uint8Array | null }, [string, string]>("SELECT r.receipt_digest,r.expires_at,r.state,o.wire FROM receipt_outbox r LEFT JOIN outbox o ON o.room=r.room AND o.digest=r.receipt_digest WHERE r.room=? AND r.inbound_digest=? ORDER BY r.created_at,o.rowid").all(state.current.room, inboundDigest);
998
+ const first = rows[0];
999
+ if (first && rows.some(row => row.expires_at !== first.expires_at)) fail("corrupt");
1000
+ const acceptedProof = [...rows].reverse().find(row => row.state === "accepted");
1001
+ const same = rows.find(row => row.receipt_digest === frame.digest);
1002
+ if (same) {
1003
+ if (same.state === "accepted") return { digest: same.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
1004
+ if (!same.wire || Buffer.compare(Buffer.from(wire), Buffer.from(same.wire))) fail("state_changed");
1005
+ return { digest: same.receipt_digest, wire: copy(same.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
1006
+ }
1007
+ const pending = rows.filter(row => row.state === "dispatching" && row.wire);
1008
+ if (rows.length && (!options.replaceExisting || first!.expires_at <= now)) {
1009
+ const selected = pending[pending.length - 1] ?? rows[rows.length - 1]!;
1010
+ if (selected.state === "dispatching") {
1011
+ if (!selected.wire) fail("corrupt");
1012
+ return { digest: selected.receipt_digest, wire: copy(selected.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
1013
+ }
1014
+ return { digest: selected.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
1015
+ }
1016
+ if (options.replaceExisting && pending.length) {
1017
+ for (const row of pending) {
1018
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, row.receipt_digest);
1019
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, row.receipt_digest);
1020
+ }
1021
+ }
1022
+ const proofCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM receipt_outbox WHERE room=?").get(state.current.room)?.n ?? 0;
1023
+ if (proofCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) {
1024
+ if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
1025
+ fail("limit");
1026
+ }
1027
+ const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(state.current.room)?.n ?? 0;
1028
+ const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
1029
+ if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) {
1030
+ if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
1031
+ fail("limit");
1032
+ }
1033
+ const expires = first?.expires_at ?? now + options.ttlMs;
1034
+ if (!Number.isSafeInteger(expires)) fail("invalid");
1035
+ this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
1036
+ this.db.query("INSERT INTO receipt_outbox(room,inbound_digest,receipt_digest,created_at,expires_at,state,recipient,epoch,roster_digest) VALUES(?,?,?,?,?,'dispatching',?,?,?)").run(frame.room, inboundDigest, frame.digest, now, expires, frame.recipients[0]!, frame.epoch, frame.rosterDigest);
1037
+ return { digest: frame.digest, wire: copy(wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: expires };
1038
+ });
1039
+ }
1040
+ retireReceiptFor(name: string, inboundDigest: string): boolean {
1041
+ const state = this.get(name); if (!state || state.left) return false;
1042
+ if (!validDigest(inboundDigest)) fail("invalid");
1043
+ return this.transaction(() => {
1044
+ const receipt = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND inbound_digest=? LIMIT 1").get(state.current.room, inboundDigest);
1045
+ if (!receipt) return false;
1046
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT receipt_digest FROM receipt_outbox WHERE room=? AND inbound_digest=?)").run(state.current.room, state.current.room, inboundDigest);
1047
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest=?").run(state.current.room, inboundDigest);
1048
+ return true;
1049
+ });
1050
+ }
1051
+ /** Broker storage ends this receipt attempt's local replay responsibility. */
1052
+ retireStoredReceipt(name: string, receiptDigest: string): boolean {
1053
+ const state = this.get(name); if (!state || state.left) return false;
1054
+ if (!validDigest(receiptDigest)) fail("invalid");
1055
+ return this.transaction(() => {
1056
+ const row = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND receipt_digest=? AND state='accepted'").get(state.current.room, receiptDigest);
1057
+ if (!row) return false;
1058
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, receiptDigest);
1059
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, receiptDigest);
1060
+ return true;
1061
+ });
1062
+ }
1063
+ expireReceipts(name: string): string[] {
1064
+ const state = this.get(name); if (!state || state.left) return [];
1065
+ return this.transaction(now => {
1066
+ const rows = this.db.query<{ inbound_digest: string }, [string, number]>("SELECT DISTINCT inbound_digest FROM receipt_outbox WHERE room=? AND expires_at<=? ORDER BY inbound_digest").all(state.current.room, now);
1067
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT receipt_digest FROM receipt_outbox WHERE room=? AND expires_at<=?)").run(state.current.room, state.current.room, now);
1068
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND expires_at<=?").run(state.current.room, now);
1069
+ return rows.map(row => row.inbound_digest);
1070
+ });
1071
+ }
1072
+ /** Recheck retained intent immediately before a delayed transport send. */
1073
+ applicationSendable(name: string, frameDigest: string): boolean {
1074
+ const state = this.get(name); if (!state || state.left || !validDigest(frameDigest)) return false;
1075
+ const now = this.now();
1076
+ return !!this.db.query("SELECT 1 FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id JOIN outbox o ON o.room=t.room AND o.digest=t.digest WHERE t.room=? AND t.digest=? AND a.state IN ('dispatching','accepted') AND a.disposition IS NULL AND a.expires_at>?")
1077
+ .get(state.current.room, frameDigest, now);
1078
+ }
1079
+ async outgoingMessage(name: string, application: string, current?: () => boolean): Promise<RoomMessage | undefined> {
1080
+ if (!validUuid(application)) fail("invalid");
1081
+ assertCurrent(current);
1082
+ const state = this.applicationRoom(name, current), now = this.now();
1083
+ const row = this.db.query<OutgoingRow, [string, string, number]>("SELECT * FROM application_outbox WHERE room=? AND application=? AND state IN ('dispatching','accepted') AND disposition IS NULL AND expires_at>? ORDER BY rowid DESC LIMIT 1").get(state.current.room, application, now);
1084
+ if (!row || row.disposition !== null || row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at) return undefined;
1085
+ const sealed = this.db.query<{ digest: string; wire: Uint8Array; roster: Uint8Array }, [string, string]>("SELECT a.digest,o.wire,o.roster FROM application_attempts a JOIN outbox o ON o.room=a.room AND o.digest=a.digest WHERE a.room=? AND a.delivery_id=? ORDER BY a.created_at DESC,o.rowid DESC LIMIT 1").get(row.room, row.delivery_id);
1086
+ if (!sealed) return undefined;
1087
+ const frame = decodeFrame(sealed.wire), installed = decodeRoster(state.wire);
1088
+ if (await digest(sealed.wire) !== sealed.digest || frame.room !== state.current.room || frame.sender !== this.identity.public.fingerprint
1089
+ || frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient) fail("corrupt");
1090
+ const selected = frame.epoch === state.current.epoch ? state.current
1091
+ : state.previous && now < state.previous.expiresAt && frame.epoch === state.previous.epoch.epoch ? state.previous.epoch : undefined;
1092
+ if (!selected) fail("state_changed");
1093
+ const epoch = await acceptRoomEpoch({ wire: sealed.roster, identity: this.identity, pin: { room: state.current.room, owner: installed.owner, minEpoch: selected.epoch, digest: selected.digest } });
1094
+ if (epoch.epoch !== selected.epoch || epoch.digest !== selected.digest) fail("state_changed");
1095
+ const plaintext = await openOwnFrame({ wire: sealed.wire, identity: this.identity, epoch });
1096
+ let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("corrupt"); }
1097
+ if (message.type !== "chat" || message.envelope.id !== row.application || message.envelope.to !== row.peer) fail("corrupt");
1098
+ if (row.content_hash !== null && await digest(encodeRoomMessage(message)) !== row.content_hash) fail("corrupt");
1099
+ assertCurrent(current);
1100
+ const latest = this.get(name), latestRow = this.db.query<{ state: RoomOutgoing["state"]; disposition: string | null; expires_at: number }, [string, string, string]>("SELECT state,disposition,expires_at FROM application_outbox WHERE room=? AND delivery_id=? AND application=?").get(row.room, row.delivery_id, application);
1101
+ if (!latest || latest.left || latest.current.digest !== state.current.digest || !latestRow
1102
+ || latestRow.disposition !== null || latestRow.state !== "dispatching" && latestRow.state !== "accepted" || this.now() >= latestRow.expires_at
1103
+ || !this.db.query("SELECT 1 FROM outbox WHERE room=? AND digest=?").get(row.room, sealed.digest)) return undefined;
1104
+ return message;
777
1105
  }
778
1106
  applicationOutbox(name: string, activeOnly = false): RoomOutgoing[] {
779
1107
  const state = this.get(name); if (!state) return [];
780
1108
  const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
781
1109
  return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
782
1110
  }
783
- /** A local send failure after durable enqueue is uncertainty, never evidence of a safe retry. */
1111
+ /** Sealed, unresolved intents that a person must see before changing the epoch. */
1112
+ rotationPending(name: string): RoomOutgoing[] {
1113
+ return this.applicationOutbox(name, true).filter(row => row.state === "dispatching" || row.state === "accepted");
1114
+ }
1115
+ /** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
784
1116
  quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
785
1117
  const state = this.get(name); if (!state) return undefined;
786
1118
  if (!boundedId(deliveryId)) fail("invalid");
787
- return this.transaction(() => {
1119
+ return this.transaction(now => {
788
1120
  const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
789
1121
  if (!row) return undefined;
790
1122
  if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
791
- this.db.query("UPDATE application_outbox SET state='needs_review' WHERE digest=?").run(row.digest);
1123
+ if (now < row.expires_at) return outgoing(row);
1124
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
792
1125
  return outgoing({ ...row, state: "needs_review" });
793
1126
  });
794
1127
  }
1128
+ quarantineOutgoingPermanent(name: string, deliveryId: string, reason: (typeof PERMANENT_OUTGOING_REFUSALS)[number] = "boundary_refused", current?: () => boolean): RoomOutgoing | undefined {
1129
+ assertCurrent(current);
1130
+ const state = this.get(name); if (!state) return undefined;
1131
+ if (!boundedId(deliveryId) || !PERMANENT_OUTGOING_REFUSALS.includes(reason)) fail("invalid");
1132
+ return this.transaction(() => {
1133
+ assertCurrent(current);
1134
+ const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
1135
+ if (!row) return undefined;
1136
+ if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
1137
+ this.db.query("UPDATE application_outbox SET state='needs_review',reason=? WHERE room=? AND delivery_id=?").run(reason, row.room, row.delivery_id);
1138
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
1139
+ return outgoing({ ...row, state: "needs_review", reason });
1140
+ });
1141
+ }
1142
+ dueApplicationRetries(name: string): RoomOutgoing[] {
1143
+ const state = this.applicationRoom(name);
1144
+ return this.transaction(now => {
1145
+ const rows = this.db.query<OutgoingRow, [string, number, number]>("SELECT * FROM application_outbox WHERE room=? AND state IN ('dispatching','accepted') AND disposition IS NULL AND ack_deadline<=? AND expires_at>? ORDER BY created_at,rowid")
1146
+ .all(state.current.room, now, now);
1147
+ for (const row of rows) {
1148
+ const next = Math.min(now + row.ack_timeout, row.expires_at);
1149
+ this.db.query("UPDATE application_outbox SET ack_deadline=? WHERE room=? AND delivery_id=?").run(next, row.room, row.delivery_id);
1150
+ }
1151
+ return rows.map(row => outgoing({ ...row, ack_deadline: Math.min(now + row.ack_timeout, row.expires_at) }));
1152
+ });
1153
+ }
795
1154
  markApplicationStored(name: string, digestValue: string, seq: string): RoomOutgoing | undefined {
796
1155
  const state = this.applicationRoom(name);
797
1156
  return this.transaction(now => {
798
- const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND digest=?").get(state.current.room, digestValue);
1157
+ const attempt = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(state.current.room, digestValue);
1158
+ const row = attempt ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, attempt.delivery_id) : undefined;
799
1159
  const sealed = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(digestValue);
800
1160
  if (!row || !sealed || decodeFrame(sealed.wire).seq !== seq) return undefined;
801
1161
  if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
802
1162
  if (!Number.isSafeInteger(row.ack_deadline)) fail("corrupt");
803
- if (now >= row.ack_deadline! || now >= row.expires_at) {
804
- this.db.query("UPDATE application_outbox SET state='needs_review' WHERE digest=?").run(digestValue);
1163
+ if (now >= row.expires_at) {
1164
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
805
1165
  return outgoing({ ...row, state: "needs_review" });
806
1166
  }
807
1167
  if (row.state === "accepted") return outgoing(row);
808
- this.db.query("UPDATE application_outbox SET state='accepted' WHERE digest=?").run(digestValue);
1168
+ this.db.query("UPDATE application_outbox SET state='accepted' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
809
1169
  this.db.query("UPDATE outbox SET state='accepted' WHERE digest=?").run(digestValue);
810
1170
  return outgoing({ ...row, state: "accepted" });
811
1171
  });
@@ -826,18 +1186,22 @@ export class RoomStore {
826
1186
  if (!entry) return undefined;
827
1187
  let message: RoomMessage; try { message = decodeRoomMessage(entry.plaintext); } catch { fail("invalid"); }
828
1188
  if (message.type !== "receipt") return undefined;
829
- const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND digest=?").get(state.current.room, message.frame);
1189
+ const attempt = this.db.query<{ delivery_id: string }, [string, string]>("SELECT delivery_id FROM application_attempts WHERE room=? AND digest=?").get(state.current.room, message.frame);
1190
+ const row = attempt ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, attempt.delivery_id) : undefined;
830
1191
  if (!row || row.application !== message.application || row.peer !== message.peer || row.recipient !== entry.sender
831
1192
  || !roomMembers(state.current).some(member => member.fingerprint === entry.sender) || !validOutcome(message.outcome, message.reason, row.peer)) return undefined;
832
1193
  if (isTerminal(row.state) || row.state === "needs_review" && row.late_outcome !== null) return outgoing(row);
833
- const expired = now >= row.expires_at || row.ack_deadline !== null && now >= row.ack_deadline;
1194
+ const expired = now >= row.expires_at;
834
1195
  if (row.state === "needs_review" || expired) {
835
- this.db.query("UPDATE application_outbox SET state='needs_review',late_outcome=?,late_reason=?,receipt_digest=? WHERE digest=?")
836
- .run(message.outcome, message.reason, receiptFrameDigest, row.digest);
837
- return outgoing({ ...row, state: "needs_review", late_outcome: message.outcome, late_reason: message.reason, receipt_digest: receiptFrameDigest });
1196
+ const settledLate = row.disposition === null && row.reason === null;
1197
+ this.db.query("UPDATE application_outbox SET state=CASE WHEN ? THEN ? ELSE state END,reason=CASE WHEN ? THEN ? ELSE reason END,late_outcome=?,late_reason=?,receipt_digest=? WHERE room=? AND delivery_id=?")
1198
+ .run(settledLate, message.outcome, settledLate, message.reason, message.outcome, message.reason, receiptFrameDigest, row.room, row.delivery_id);
1199
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
1200
+ this.pruneOutgoingHistory(state.current.room);
1201
+ return outgoing({ ...row, state: settledLate ? message.outcome : row.state, reason: settledLate ? message.reason : row.reason, late_outcome: message.outcome, late_reason: message.reason, receipt_digest: receiptFrameDigest });
838
1202
  }
839
- this.db.query("UPDATE application_outbox SET state=?,reason=?,receipt_digest=? WHERE digest=?").run(message.outcome, message.reason, receiptFrameDigest, row.digest);
840
- this.db.query("DELETE FROM outbox WHERE digest=?").run(row.digest);
1203
+ this.db.query("UPDATE application_outbox SET state=?,reason=?,receipt_digest=? WHERE room=? AND delivery_id=?").run(message.outcome, message.reason, receiptFrameDigest, row.room, row.delivery_id);
1204
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
841
1205
  this.pruneOutgoingHistory(state.current.room);
842
1206
  return outgoing({ ...row, state: message.outcome, reason: message.reason, receipt_digest: receiptFrameDigest });
843
1207
  });
@@ -845,39 +1209,50 @@ export class RoomStore {
845
1209
  private pruneOutgoingHistory(room: string): void {
846
1210
  this.db.query("DELETE FROM application_outbox WHERE room=? AND (state IN ('completed','failed_safe') OR disposition IS NOT NULL) AND rowid NOT IN (SELECT rowid FROM application_outbox WHERE room=? AND (state IN ('completed','failed_safe') OR disposition IS NOT NULL) ORDER BY rowid DESC LIMIT ?)")
847
1211
  .run(room, room, APPLICATION_HISTORY_CAP);
1212
+ this.db.query("DELETE FROM application_attempts WHERE room=? AND delivery_id NOT IN (SELECT delivery_id FROM application_outbox WHERE room=?)").run(room, room);
848
1213
  }
849
1214
  /** Local disposition follows a successful person Bus resolution; it is never remote completion proof. */
850
- disposeApplication(name: string, deliveryId: string, action: "completed" | "retry" | "discard", current?: () => boolean): RoomOutgoing | undefined {
1215
+ disposeApplication(name: string, deliveryId: string, action: "completed" | "retry" | "discard", authoritative: true, current: () => boolean): RoomOutgoing | undefined {
851
1216
  assertCurrent(current);
852
1217
  const state = this.get(name); if (!state) return undefined;
1218
+ if (authoritative !== true) fail("untrusted");
853
1219
  if (!boundedId(deliveryId) || !["completed", "retry", "discard"].includes(action)) fail("invalid");
854
1220
  return this.transaction(() => {
855
1221
  assertCurrent(current);
856
1222
  const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
857
- if (!row || row.state !== "needs_review") return undefined;
1223
+ if (!row || !["dispatching", "accepted", "needs_review", "completed", "failed_safe"].includes(row.state)) return undefined;
858
1224
  if (row.disposition !== null) { if (row.disposition !== action) fail("state_changed"); return outgoing(row); }
859
- this.db.query("UPDATE application_outbox SET disposition=? WHERE digest=?").run(action, row.digest);
860
- this.db.query("DELETE FROM outbox WHERE digest=?").run(row.digest);
1225
+ const nextState = row.state === "dispatching" || row.state === "accepted" ? "needs_review" : row.state;
1226
+ this.db.query("UPDATE application_outbox SET state=?,disposition=? WHERE room=? AND delivery_id=?").run(nextState, action, row.room, row.delivery_id);
1227
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT digest FROM application_attempts WHERE room=? AND delivery_id=?)").run(row.room, row.room, row.delivery_id);
861
1228
  this.pruneOutgoingHistory(state.current.room);
862
- return outgoing({ ...row, disposition: action });
1229
+ return outgoing({ ...row, state: nextState, disposition: action });
863
1230
  });
864
1231
  }
865
1232
  expireApplications(name: string): RoomOutgoing[] {
866
1233
  const state = this.applicationRoom(name);
867
1234
  return this.transaction(now => {
868
- const rows = this.db.query<OutgoingRow, [string, number, number]>("SELECT * FROM application_outbox WHERE room=? AND state IN ('dispatching','accepted') AND (expires_at<=? OR ack_deadline<=?) ORDER BY rowid").all(state.current.room, now, now);
869
- for (const row of rows) this.db.query("UPDATE application_outbox SET state='needs_review' WHERE digest=?").run(row.digest);
1235
+ const rows = this.db.query<OutgoingRow, [string, number]>("SELECT * FROM application_outbox WHERE room=? AND state IN ('dispatching','accepted') AND expires_at<=? ORDER BY rowid").all(state.current.room, now);
1236
+ for (const row of rows) {
1237
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
1238
+ }
870
1239
  return rows.map(row => outgoing({ ...row, state: "needs_review" }));
871
1240
  });
872
1241
  }
873
1242
  private applicationFor(room: string, digestValue: string): ApplicationRow | undefined {
874
1243
  return this.db.query<ApplicationRow, [string, string]>("SELECT a.* FROM applications a JOIN application_frames f ON f.room=a.room AND f.sender=a.sender AND f.application=a.application WHERE f.room=? AND f.digest=?").get(room, digestValue) ?? undefined;
875
1244
  }
876
- private personIdentityRetained(row: Pick<ApplicationRow, "room" | "sender" | "application">): boolean {
877
- return !!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application);
1245
+ private personIdentityRetained(row: Pick<ApplicationRow, "room" | "sender" | "application">, now = this.now()): boolean {
1246
+ return !!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application)
1247
+ || !!this.db.query("SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=? AND f.sender=? AND f.application=? AND r.state='dispatching' AND r.expires_at>?").get(row.room, row.sender, row.application, now);
878
1248
  }
879
- private deleteSettledApplication(row: ApplicationRow): void {
880
- if (this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=? AND state='unread'").get(row.room, row.sender, row.application)) return;
1249
+ private deleteSettledApplication(row: ApplicationRow, now = this.now()): void {
1250
+ if (this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=? AND state='unread'").get(row.room, row.sender, row.application)
1251
+ || this.db.query("SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=? AND f.sender=? AND f.application=? AND r.state='dispatching' AND r.expires_at>?").get(row.room, row.sender, row.application, now)) return;
1252
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest IN (SELECT r.receipt_digest FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=? AND f.sender=? AND f.application=?)")
1253
+ .run(row.room, row.room, row.sender, row.application);
1254
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
1255
+ .run(row.room, row.room, row.sender, row.application);
881
1256
  this.db.query("DELETE FROM person_inbox WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
882
1257
  this.db.query("DELETE FROM inbox WHERE room=? AND plaintext IS NULL AND digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
883
1258
  .run(row.room, row.room, row.sender, row.application);
@@ -888,16 +1263,17 @@ export class RoomStore {
888
1263
  this.prunePersonInbox(now);
889
1264
  // Person stubs have their own disposition/expiry horizon. Only the cap walk
890
1265
  // below may evict a settled stub early; unread identities are never candidates.
891
- const expired = this.db.query<ApplicationRow, [string, number, string, string]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND settled_at<=? AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application)").all(room, now - ROOM_APPLICATION_RETENTION.settledMs, retained?.sender ?? "", retained?.application ?? "");
892
- for (const row of expired) this.deleteSettledApplication(row);
1266
+ const expired = this.db.query<ApplicationRow, [string, number, string, string, number]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND settled_at<=? AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application) AND NOT EXISTS (SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=applications.room AND f.sender=applications.sender AND f.application=applications.application AND r.state='dispatching' AND r.expires_at>?)").all(room, now - ROOM_APPLICATION_RETENTION.settledMs, retained?.sender ?? "", retained?.application ?? "", now);
1267
+ for (const row of expired) this.deleteSettledApplication(row, now);
893
1268
  for (;;) {
894
1269
  const identities = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM applications WHERE room=?").get(room)?.n ?? 0;
895
1270
  const aliases = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_frames WHERE room=?").get(room)?.n ?? 0;
896
1271
  if ((!freshIdentity || identities < ROOM_APPLICATION_RETENTION.identitiesPerRoom) && aliases < ROOM_APPLICATION_RETENTION.aliasesPerRoom) return;
1272
+ // ponytail: automatic resend can reach this settled-identity eviction and admit an old application again. Pin retryable identities or add #737 receipt confirmation before claiming dedupe beyond this ceiling.
897
1273
  // A person identity becomes settled at done/discard/expiry, not at its earlier admission.
898
- const settled = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application AND p.state='unread') ORDER BY COALESCE((SELECT p.settled_at FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application),settled_at),created_at,sender,application LIMIT 1").get(room, retained?.sender ?? "", retained?.application ?? "");
1274
+ const settled = this.db.query<ApplicationRow, [string, string, string, number]>("SELECT * FROM applications WHERE room=? AND state IN ('completed','failed_safe','disposed') AND NOT (sender=? AND application=?) AND NOT EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application AND p.state='unread') AND NOT EXISTS (SELECT 1 FROM receipt_outbox r JOIN application_frames f ON f.room=r.room AND f.digest=r.inbound_digest WHERE f.room=applications.room AND f.sender=applications.sender AND f.application=applications.application AND r.state='dispatching' AND r.expires_at>?) ORDER BY COALESCE((SELECT p.settled_at FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application),settled_at),created_at,sender,application LIMIT 1").get(room, retained?.sender ?? "", retained?.application ?? "", now);
899
1275
  if (!settled) throw new RoomApplicationLimitError(room, Math.max(identities, aliases));
900
- this.deleteSettledApplication(settled);
1276
+ this.deleteSettledApplication(settled, now);
901
1277
  }
902
1278
  }
903
1279
  async applicationIncoming(name: string, digestValue: string, current?: () => boolean): Promise<{ sender: string; chat: RoomChatEnvelope; contentHash: string; primary: boolean; state: ApplicationState }> {
@@ -918,7 +1294,7 @@ export class RoomStore {
918
1294
  let saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key);
919
1295
  if (saved && saved.settled_at !== null && saved.settled_at <= now - ROOM_APPLICATION_RETENTION.settledMs && isSettledApplication(saved.state)) {
920
1296
  this.prunePersonInbox(now);
921
- if (!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(saved.room, saved.sender, saved.application)) { this.deleteSettledApplication(saved); saved = null; }
1297
+ if (!this.personIdentityRetained(saved)) { this.deleteSettledApplication(saved); saved = null; }
922
1298
  }
923
1299
  if (saved && (saved.content_hash !== contentHash || saved.peer !== chat.to)) fail("invalid");
924
1300
  const alias = this.applicationFor(state.current.room, digestValue);
@@ -1152,8 +1528,14 @@ export class RoomStore {
1152
1528
  return row.hop!;
1153
1529
  }
1154
1530
  private recoverApplications(): void {
1155
- this.transaction(() => {
1156
- this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted')").run();
1531
+ this.transaction(now => {
1532
+ this.db.query("UPDATE application_outbox SET state='dispatching',ack_deadline=MIN(?+ack_timeout,expires_at) WHERE state='needs_review' AND disposition IS NULL AND late_outcome IS NULL AND reason IS NULL AND expires_at>?").run(now, now);
1533
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted') AND expires_at<=?").run(now);
1534
+ const lateRows = this.db.query<OutgoingRow, []>("SELECT * FROM application_outbox WHERE state='needs_review' AND late_outcome IS NOT NULL AND disposition IS NULL AND reason IS NULL").all();
1535
+ for (const row of lateRows) if (this.lateReceiptProof(row) === "valid") {
1536
+ this.db.query("UPDATE application_outbox SET state=late_outcome,reason=late_reason WHERE room=? AND delivery_id=? AND disposition IS NULL AND reason IS NULL").run(row.room, row.delivery_id);
1537
+ }
1538
+ this.db.query("DELETE FROM outbox WHERE EXISTS (SELECT 1 FROM application_attempts t JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id WHERE t.room=outbox.room AND t.digest=outbox.digest AND (a.state IN ('completed','failed_safe') OR a.state='needs_review' AND (a.reason IS NOT NULL OR a.disposition IS NOT NULL OR a.late_outcome IS NOT NULL)))").run();
1157
1539
  this.db.query("UPDATE applications SET state='needs_review' WHERE state='dispatching'").run();
1158
1540
  this.db.query("UPDATE inbox SET plaintext=NULL WHERE EXISTS (SELECT 1 FROM application_frames f JOIN applications a ON a.room=f.room AND a.sender=f.sender AND a.application=f.application WHERE f.room=inbox.room AND f.digest=inbox.digest AND a.state='needs_review')").run();
1159
1541
  for (const state of this.rooms.values()) this.pruneInbox(state.current.room);