@staix/agent-hub 0.29.0 → 0.31.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -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 +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. 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
  }
@@ -735,6 +819,32 @@ export class RoomStore {
735
819
  const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
736
820
  const frame = await verifyPublicFrame({ wire, roster: publicRoster });
737
821
  if (frame.sender !== this.identity.public.fingerprint) fail("untrusted");
822
+ let contentHash: string | undefined;
823
+ let legacyContentHash: string | undefined;
824
+ let legacyDeliveryId: string | undefined;
825
+ let legacyAttemptDigest: string | undefined;
826
+ if (application) {
827
+ const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
828
+ let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("invalid"); }
829
+ if (message.type !== "chat" || message.envelope.id !== application.application || message.envelope.to !== application.peer) fail("state_changed");
830
+ contentHash = await digest(encodeRoomMessage(message));
831
+ const saved = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND application=? ORDER BY rowid").all(frame.room, application.application);
832
+ const legacy = saved.filter(row => row.content_hash === null);
833
+ if (legacy.length) {
834
+ if (saved.length !== 1 || legacy[0]!.delivery_id !== application.deliveryId
835
+ || legacy[0]!.state !== "dispatching" && legacy[0]!.state !== "accepted" || legacy[0]!.disposition !== null
836
+ || legacy[0]!.reason !== null || legacy[0]!.expires_at <= this.now()) fail("state_changed");
837
+ legacyDeliveryId = legacy[0]!.delivery_id;
838
+ 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;
839
+ if (!legacyAttemptDigest) fail("state_changed");
840
+ const previous = await this.outgoingMessage(name, application.application, current);
841
+ if (!previous || previous.type !== "chat") fail("state_changed");
842
+ legacyContentHash = await digest(encodeRoomMessage(previous));
843
+ if (legacyContentHash !== contentHash) fail("state_changed");
844
+ 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;
845
+ if (afterReadDigest !== legacyAttemptDigest) fail("state_changed");
846
+ }
847
+ }
738
848
  return this.transaction(now => {
739
849
  assertCurrent(current);
740
850
  if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
@@ -743,69 +853,309 @@ export class RoomStore {
743
853
  if (!recipient || application.recipient === this.identity.public.fingerprint || frame.recipients.length !== 1
744
854
  || frame.recipients[0] !== application.recipient || application.remotePeer !== `${application.peer}@${recipient.label}`) fail("untrusted");
745
855
  }
856
+ 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) : [];
857
+ if (application) {
858
+ if (contentHash === undefined) fail("corrupt");
859
+ for (const prior of applicationRows) {
860
+ if (prior.remote_peer !== application.remotePeer || prior.recipient !== application.recipient || prior.peer !== application.peer
861
+ || prior.ack_timeout !== application.ackTimeoutMs || prior.expires_at - prior.created_at !== application.ttlMs
862
+ || prior.expires_at <= now || prior.disposition !== null || prior.state === "needs_review"
863
+ || prior.reason !== null && PERMANENT_OUTGOING_REFUSALS.includes(prior.reason)) fail("state_changed");
864
+ if (prior.content_hash !== null && prior.content_hash !== contentHash) fail("state_changed");
865
+ if (prior.content_hash === null && (legacyContentHash !== contentHash || prior.delivery_id !== legacyDeliveryId)) fail("state_changed");
866
+ }
867
+ if (legacyContentHash === contentHash && legacyDeliveryId) {
868
+ 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);
869
+ if (attempt?.digest !== legacyAttemptDigest) fail("state_changed");
870
+ 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);
871
+ if (update.changes !== 1) {
872
+ 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;
873
+ if (currentHash !== contentHash) fail("state_changed");
874
+ }
875
+ }
876
+ }
746
877
  const existing = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(frame.digest);
747
878
  const saved = this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest);
748
879
  if (existing || saved) {
880
+ if (!existing) fail("state_changed");
749
881
  if (existing && Buffer.compare(Buffer.from(wire), Buffer.from(existing.wire))) fail("state_changed");
750
882
  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);
883
+ 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);
884
+ 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);
885
+ if (!row || row.delivery_id !== application.deliveryId || row.application !== application.application
886
+ || row.remote_peer !== application.remotePeer || row.recipient !== application.recipient || row.peer !== application.peer
887
+ || row.ack_timeout !== application.ackTimeoutMs || row.expires_at - row.created_at !== application.ttlMs || row.content_hash !== contentHash) fail("state_changed");
888
+ return { ...outgoing(row), frameDigest: frame.digest };
755
889
  }
756
- if (application && this.db.query("SELECT 1 FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId)) fail("state_changed");
890
+ const row = application ? this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId) : undefined;
891
+ if (application && this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(frame.room, frame.digest)) fail("state_changed");
892
+ if (row && (row.application !== application!.application || 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.disposition !== null
894
+ || row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at || row.content_hash !== contentHash)) fail("state_changed");
895
+ const aliasCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_attempts WHERE room=?").get(frame.room)?.n ?? 0;
896
+ if (application && aliasCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) fail("limit");
897
+ 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
898
  const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(frame.room)?.n ?? 0;
758
899
  const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
759
900
  if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) fail("limit");
760
901
  this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
761
902
  if (!application) return undefined;
762
- const expires = now + application.ttlMs, deadline = now + application.ackTimeoutMs;
903
+ if (row) {
904
+ const deadline = Math.min(now + application.ackTimeoutMs, row.expires_at);
905
+ if (!Number.isSafeInteger(deadline)) fail("invalid");
906
+ 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);
907
+ this.db.query("UPDATE application_outbox SET ack_deadline=?,state='dispatching' WHERE room=? AND delivery_id=?").run(deadline, frame.room, row.delivery_id);
908
+ const updated = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, row.delivery_id)!;
909
+ return { ...outgoing(updated), frameDigest: frame.digest };
910
+ }
911
+ const canonical = applicationRows[0];
912
+ const expires = canonical?.expires_at ?? now + application.ttlMs, deadline = Math.min(now + application.ackTimeoutMs, expires);
763
913
  if (!Number.isSafeInteger(expires) || !Number.isSafeInteger(deadline)) fail("invalid");
764
914
  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
915
  .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)!);
916
+ 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);
917
+ 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);
918
+ return { ...outgoing(this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest)!), frameDigest: frame.digest };
767
919
  });
768
920
  }
769
- outbox(name: string): { digest: string; wire: Uint8Array; state: "dispatching" | "accepted" }[] {
921
+ outbox(name: string): RoomOutboxEntry[] {
770
922
  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) }));
923
+ const now = this.now();
924
+ 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 } : {}) }))
925
+ .filter(row => row.kind !== "receipt" || this.receiptSendable(name, row.digest));
772
926
  }
773
- markStored(name: string, digestValue: string): void {
927
+ markStored(name: string, digestValue: string, seq?: string): void {
774
928
  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); });
929
+ if (this.db.query("SELECT 1 FROM application_attempts WHERE room=? AND digest=?").get(room.current.room, digestValue)) fail("invalid");
930
+ this.transaction(now => {
931
+ if (seq !== undefined) {
932
+ if (!/^[1-9][0-9]{0,19}$/.test(seq)) fail("invalid");
933
+ const sealed = this.db.query<{ wire: Uint8Array }, [string, string]>("SELECT wire FROM outbox WHERE room=? AND digest=?").get(room.current.room, digestValue);
934
+ if (!sealed || decodeFrame(sealed.wire).seq !== seq) return;
935
+ }
936
+ 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);
937
+ if (receipt && receipt.expires_at <= now) return;
938
+ this.db.query("UPDATE outbox SET state='accepted' WHERE room=? AND digest=?").run(room.current.room, digestValue);
939
+ if (receipt) {
940
+ this.db.query("UPDATE receipt_outbox SET state='accepted' WHERE room=? AND receipt_digest=?").run(room.current.room, digestValue);
941
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(room.current.room, digestValue);
942
+ }
943
+ });
944
+ }
945
+ receiptStored(name: string, inboundDigest: string): RoomStoredReceipt | undefined {
946
+ const state = this.get(name); if (!state || state.left) return undefined;
947
+ if (!validDigest(inboundDigest)) fail("invalid");
948
+ 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);
949
+ if (!row) return undefined;
950
+ if (row.state === "dispatching") {
951
+ if (!row.wire) fail("corrupt");
952
+ return { digest: row.receipt_digest, wire: copy(row.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
953
+ }
954
+ return { digest: row.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: row.expires_at };
955
+ }
956
+ receiptSendable(name: string, receiptDigest: string): boolean {
957
+ const state = this.get(name); if (!state || state.left || !validDigest(receiptDigest)) return false;
958
+ 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'")
959
+ .get(state.current.room, receiptDigest, this.now());
960
+ if (!row) return false;
961
+ const frame = decodeFrame(row.wire), recipient = frame.recipients[0], now = this.now();
962
+ const members = roomMembers(state.current);
963
+ const currentMember = recipient && members.some(member => member.fingerprint === recipient);
964
+ const senderCurrent = members.some(member => member.fingerprint === this.identity.public.fingerprint);
965
+ const currentEpoch = row.epoch === state.current.epoch && row.roster_digest === state.current.digest;
966
+ const previousEpoch = state.previous && now < state.previous.expiresAt && row.epoch === state.previous.epoch.epoch && row.roster_digest === state.previous.epoch.digest;
967
+ return row.epoch === frame.epoch && validDigest(row.roster_digest) && frame.room === state.current.room && frame.sender === this.identity.public.fingerprint
968
+ && frame.recipients.length === 1 && currentMember === true && senderCurrent && (currentEpoch || previousEpoch === true);
969
+ }
970
+ async queueReceipt(name: string, inboundDigest: string, wire: Uint8Array, options: { ttlMs: number; replaceExisting?: boolean }, current?: () => boolean): Promise<RoomStoredReceipt> {
971
+ if (!validDigest(inboundDigest) || !options || !Number.isSafeInteger(options.ttlMs) || options.ttlMs < 1 || options.ttlMs > 604_800_000) fail("invalid");
972
+ wire = copy(wire);
973
+ const state = this.get(name); if (!state || state.left) fail("untrusted");
974
+ const roster = decodeRoster(state.wire);
975
+ const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
976
+ const frame = await verifyPublicFrame({ wire, roster: publicRoster });
977
+ if (frame.sender !== this.identity.public.fingerprint || frame.recipients.length !== 1) fail("untrusted");
978
+ const plaintext = await openOwnFrame({ wire, identity: this.identity, epoch: state.current });
979
+ let decoded: RoomMessage; try { decoded = decodeRoomMessage(plaintext); } catch { fail("untrusted"); }
980
+ const expected = this.applicationReceipt(name, inboundDigest);
981
+ if (!expected || Buffer.compare(Buffer.from(encodeRoomMessage(decoded)), Buffer.from(encodeRoomMessage(expected))) !== 0) fail("untrusted");
982
+ assertCurrent(current);
983
+ if (this.get(name)?.current.digest !== state.current.digest) fail("state_changed");
984
+ return this.transaction(now => {
985
+ assertCurrent(current);
986
+ if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
987
+ const incoming = this.applicationFor(state.current.room, inboundDigest);
988
+ if (!incoming || !isTerminal(incoming.state) || incoming.sender !== frame.recipients[0]) fail("untrusted");
989
+ 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);
990
+ this.db.query("DELETE FROM receipt_outbox WHERE expires_at<=?").run(now);
991
+ 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);
992
+ const first = rows[0];
993
+ if (first && rows.some(row => row.expires_at !== first.expires_at)) fail("corrupt");
994
+ const acceptedProof = [...rows].reverse().find(row => row.state === "accepted");
995
+ const same = rows.find(row => row.receipt_digest === frame.digest);
996
+ if (same) {
997
+ if (same.state === "accepted") return { digest: same.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
998
+ if (!same.wire || Buffer.compare(Buffer.from(wire), Buffer.from(same.wire))) fail("state_changed");
999
+ return { digest: same.receipt_digest, wire: copy(same.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: same.expires_at };
1000
+ }
1001
+ const pending = rows.filter(row => row.state === "dispatching" && row.wire);
1002
+ if (rows.length && (!options.replaceExisting || first!.expires_at <= now)) {
1003
+ const selected = pending[pending.length - 1] ?? rows[rows.length - 1]!;
1004
+ if (selected.state === "dispatching") {
1005
+ if (!selected.wire) fail("corrupt");
1006
+ return { digest: selected.receipt_digest, wire: copy(selected.wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
1007
+ }
1008
+ return { digest: selected.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: selected.expires_at };
1009
+ }
1010
+ if (options.replaceExisting && pending.length) {
1011
+ for (const row of pending) {
1012
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, row.receipt_digest);
1013
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, row.receipt_digest);
1014
+ }
1015
+ }
1016
+ const proofCount = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM receipt_outbox WHERE room=?").get(state.current.room)?.n ?? 0;
1017
+ if (proofCount >= ROOM_APPLICATION_RETENTION.aliasesPerRoom) {
1018
+ if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
1019
+ fail("limit");
1020
+ }
1021
+ const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(state.current.room)?.n ?? 0;
1022
+ const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
1023
+ if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) {
1024
+ if (acceptedProof) return { digest: acceptedProof.receipt_digest, state: "accepted", kind: "receipt", receiptFor: inboundDigest, expiresAt: acceptedProof.expires_at };
1025
+ fail("limit");
1026
+ }
1027
+ const expires = first?.expires_at ?? now + options.ttlMs;
1028
+ if (!Number.isSafeInteger(expires)) fail("invalid");
1029
+ this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
1030
+ 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);
1031
+ return { digest: frame.digest, wire: copy(wire), state: "dispatching", kind: "receipt", receiptFor: inboundDigest, expiresAt: expires };
1032
+ });
1033
+ }
1034
+ retireReceiptFor(name: string, inboundDigest: string): boolean {
1035
+ const state = this.get(name); if (!state || state.left) return false;
1036
+ if (!validDigest(inboundDigest)) fail("invalid");
1037
+ return this.transaction(() => {
1038
+ const receipt = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND inbound_digest=? LIMIT 1").get(state.current.room, inboundDigest);
1039
+ if (!receipt) return false;
1040
+ 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);
1041
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest=?").run(state.current.room, inboundDigest);
1042
+ return true;
1043
+ });
1044
+ }
1045
+ /** Broker storage ends this receipt attempt's local replay responsibility. */
1046
+ retireStoredReceipt(name: string, receiptDigest: string): boolean {
1047
+ const state = this.get(name); if (!state || state.left) return false;
1048
+ if (!validDigest(receiptDigest)) fail("invalid");
1049
+ return this.transaction(() => {
1050
+ const row = this.db.query("SELECT 1 FROM receipt_outbox WHERE room=? AND receipt_digest=? AND state='accepted'").get(state.current.room, receiptDigest);
1051
+ if (!row) return false;
1052
+ this.db.query("DELETE FROM outbox WHERE room=? AND digest=?").run(state.current.room, receiptDigest);
1053
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND receipt_digest=?").run(state.current.room, receiptDigest);
1054
+ return true;
1055
+ });
1056
+ }
1057
+ expireReceipts(name: string): string[] {
1058
+ const state = this.get(name); if (!state || state.left) return [];
1059
+ return this.transaction(now => {
1060
+ 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);
1061
+ 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);
1062
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND expires_at<=?").run(state.current.room, now);
1063
+ return rows.map(row => row.inbound_digest);
1064
+ });
1065
+ }
1066
+ /** Recheck retained intent immediately before a delayed transport send. */
1067
+ applicationSendable(name: string, frameDigest: string): boolean {
1068
+ const state = this.get(name); if (!state || state.left || !validDigest(frameDigest)) return false;
1069
+ const now = this.now();
1070
+ 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>?")
1071
+ .get(state.current.room, frameDigest, now);
1072
+ }
1073
+ async outgoingMessage(name: string, application: string, current?: () => boolean): Promise<RoomMessage | undefined> {
1074
+ if (!validUuid(application)) fail("invalid");
1075
+ assertCurrent(current);
1076
+ const state = this.applicationRoom(name, current), now = this.now();
1077
+ 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);
1078
+ if (!row || row.disposition !== null || row.state !== "dispatching" && row.state !== "accepted" || now >= row.expires_at) return undefined;
1079
+ 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);
1080
+ if (!sealed) return undefined;
1081
+ const frame = decodeFrame(sealed.wire), installed = decodeRoster(state.wire);
1082
+ if (await digest(sealed.wire) !== sealed.digest || frame.room !== state.current.room || frame.sender !== this.identity.public.fingerprint
1083
+ || frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient) fail("corrupt");
1084
+ const selected = frame.epoch === state.current.epoch ? state.current
1085
+ : state.previous && now < state.previous.expiresAt && frame.epoch === state.previous.epoch.epoch ? state.previous.epoch : undefined;
1086
+ if (!selected) fail("state_changed");
1087
+ const epoch = await acceptRoomEpoch({ wire: sealed.roster, identity: this.identity, pin: { room: state.current.room, owner: installed.owner, minEpoch: selected.epoch, digest: selected.digest } });
1088
+ if (epoch.epoch !== selected.epoch || epoch.digest !== selected.digest) fail("state_changed");
1089
+ const plaintext = await openOwnFrame({ wire: sealed.wire, identity: this.identity, epoch });
1090
+ let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("corrupt"); }
1091
+ if (message.type !== "chat" || message.envelope.id !== row.application || message.envelope.to !== row.peer) fail("corrupt");
1092
+ if (row.content_hash !== null && await digest(encodeRoomMessage(message)) !== row.content_hash) fail("corrupt");
1093
+ assertCurrent(current);
1094
+ 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);
1095
+ if (!latest || latest.left || latest.current.digest !== state.current.digest || !latestRow
1096
+ || latestRow.disposition !== null || latestRow.state !== "dispatching" && latestRow.state !== "accepted" || this.now() >= latestRow.expires_at
1097
+ || !this.db.query("SELECT 1 FROM outbox WHERE room=? AND digest=?").get(row.room, sealed.digest)) return undefined;
1098
+ return message;
777
1099
  }
778
1100
  applicationOutbox(name: string, activeOnly = false): RoomOutgoing[] {
779
1101
  const state = this.get(name); if (!state) return [];
780
1102
  const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
781
1103
  return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
782
1104
  }
783
- /** A local send failure after durable enqueue is uncertainty, never evidence of a safe retry. */
1105
+ /** Compatibility no-op for retryable rows; only original TTL expiry creates needs_review. */
784
1106
  quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
785
1107
  const state = this.get(name); if (!state) return undefined;
786
1108
  if (!boundedId(deliveryId)) fail("invalid");
787
- return this.transaction(() => {
1109
+ return this.transaction(now => {
788
1110
  const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
789
1111
  if (!row) return undefined;
790
1112
  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);
1113
+ if (now < row.expires_at) return outgoing(row);
1114
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
792
1115
  return outgoing({ ...row, state: "needs_review" });
793
1116
  });
794
1117
  }
1118
+ quarantineOutgoingPermanent(name: string, deliveryId: string, reason: (typeof PERMANENT_OUTGOING_REFUSALS)[number] = "boundary_refused", current?: () => boolean): RoomOutgoing | undefined {
1119
+ assertCurrent(current);
1120
+ const state = this.get(name); if (!state) return undefined;
1121
+ if (!boundedId(deliveryId) || !PERMANENT_OUTGOING_REFUSALS.includes(reason)) fail("invalid");
1122
+ return this.transaction(() => {
1123
+ assertCurrent(current);
1124
+ const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
1125
+ if (!row) return undefined;
1126
+ if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
1127
+ this.db.query("UPDATE application_outbox SET state='needs_review',reason=? WHERE room=? AND delivery_id=?").run(reason, row.room, row.delivery_id);
1128
+ 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);
1129
+ return outgoing({ ...row, state: "needs_review", reason });
1130
+ });
1131
+ }
1132
+ dueApplicationRetries(name: string): RoomOutgoing[] {
1133
+ const state = this.applicationRoom(name);
1134
+ return this.transaction(now => {
1135
+ 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")
1136
+ .all(state.current.room, now, now);
1137
+ for (const row of rows) {
1138
+ const next = Math.min(now + row.ack_timeout, row.expires_at);
1139
+ this.db.query("UPDATE application_outbox SET ack_deadline=? WHERE room=? AND delivery_id=?").run(next, row.room, row.delivery_id);
1140
+ }
1141
+ return rows.map(row => outgoing({ ...row, ack_deadline: Math.min(now + row.ack_timeout, row.expires_at) }));
1142
+ });
1143
+ }
795
1144
  markApplicationStored(name: string, digestValue: string, seq: string): RoomOutgoing | undefined {
796
1145
  const state = this.applicationRoom(name);
797
1146
  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);
1147
+ 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);
1148
+ 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
1149
  const sealed = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(digestValue);
800
1150
  if (!row || !sealed || decodeFrame(sealed.wire).seq !== seq) return undefined;
801
1151
  if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
802
1152
  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);
1153
+ if (now >= row.expires_at) {
1154
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
805
1155
  return outgoing({ ...row, state: "needs_review" });
806
1156
  }
807
1157
  if (row.state === "accepted") return outgoing(row);
808
- this.db.query("UPDATE application_outbox SET state='accepted' WHERE digest=?").run(digestValue);
1158
+ this.db.query("UPDATE application_outbox SET state='accepted' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
809
1159
  this.db.query("UPDATE outbox SET state='accepted' WHERE digest=?").run(digestValue);
810
1160
  return outgoing({ ...row, state: "accepted" });
811
1161
  });
@@ -826,18 +1176,22 @@ export class RoomStore {
826
1176
  if (!entry) return undefined;
827
1177
  let message: RoomMessage; try { message = decodeRoomMessage(entry.plaintext); } catch { fail("invalid"); }
828
1178
  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);
1179
+ 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);
1180
+ 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
1181
  if (!row || row.application !== message.application || row.peer !== message.peer || row.recipient !== entry.sender
831
1182
  || !roomMembers(state.current).some(member => member.fingerprint === entry.sender) || !validOutcome(message.outcome, message.reason, row.peer)) return undefined;
832
1183
  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;
1184
+ const expired = now >= row.expires_at;
834
1185
  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 });
1186
+ const settledLate = row.disposition === null && row.reason === null;
1187
+ 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=?")
1188
+ .run(settledLate, message.outcome, settledLate, message.reason, message.outcome, message.reason, receiptFrameDigest, row.room, row.delivery_id);
1189
+ 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);
1190
+ this.pruneOutgoingHistory(state.current.room);
1191
+ 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
1192
  }
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);
1193
+ 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);
1194
+ 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
1195
  this.pruneOutgoingHistory(state.current.room);
842
1196
  return outgoing({ ...row, state: message.outcome, reason: message.reason, receipt_digest: receiptFrameDigest });
843
1197
  });
@@ -845,39 +1199,50 @@ export class RoomStore {
845
1199
  private pruneOutgoingHistory(room: string): void {
846
1200
  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
1201
  .run(room, room, APPLICATION_HISTORY_CAP);
1202
+ 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
1203
  }
849
1204
  /** 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 {
1205
+ disposeApplication(name: string, deliveryId: string, action: "completed" | "retry" | "discard", authoritative: true, current: () => boolean): RoomOutgoing | undefined {
851
1206
  assertCurrent(current);
852
1207
  const state = this.get(name); if (!state) return undefined;
1208
+ if (authoritative !== true) fail("untrusted");
853
1209
  if (!boundedId(deliveryId) || !["completed", "retry", "discard"].includes(action)) fail("invalid");
854
1210
  return this.transaction(() => {
855
1211
  assertCurrent(current);
856
1212
  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;
1213
+ if (!row || !["dispatching", "accepted", "needs_review", "completed", "failed_safe"].includes(row.state)) return undefined;
858
1214
  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);
1215
+ const nextState = row.state === "dispatching" || row.state === "accepted" ? "needs_review" : row.state;
1216
+ this.db.query("UPDATE application_outbox SET state=?,disposition=? WHERE room=? AND delivery_id=?").run(nextState, action, row.room, row.delivery_id);
1217
+ 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
1218
  this.pruneOutgoingHistory(state.current.room);
862
- return outgoing({ ...row, disposition: action });
1219
+ return outgoing({ ...row, state: nextState, disposition: action });
863
1220
  });
864
1221
  }
865
1222
  expireApplications(name: string): RoomOutgoing[] {
866
1223
  const state = this.applicationRoom(name);
867
1224
  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);
1225
+ 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);
1226
+ for (const row of rows) {
1227
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE room=? AND delivery_id=?").run(row.room, row.delivery_id);
1228
+ }
870
1229
  return rows.map(row => outgoing({ ...row, state: "needs_review" }));
871
1230
  });
872
1231
  }
873
1232
  private applicationFor(room: string, digestValue: string): ApplicationRow | undefined {
874
1233
  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
1234
  }
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);
1235
+ private personIdentityRetained(row: Pick<ApplicationRow, "room" | "sender" | "application">, now = this.now()): boolean {
1236
+ return !!this.db.query("SELECT 1 FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application)
1237
+ || !!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
1238
  }
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;
1239
+ private deleteSettledApplication(row: ApplicationRow, now = this.now()): void {
1240
+ 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)
1241
+ || 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;
1242
+ 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=?)")
1243
+ .run(row.room, row.room, row.sender, row.application);
1244
+ this.db.query("DELETE FROM receipt_outbox WHERE room=? AND inbound_digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
1245
+ .run(row.room, row.room, row.sender, row.application);
881
1246
  this.db.query("DELETE FROM person_inbox WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
882
1247
  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
1248
  .run(row.room, row.room, row.sender, row.application);
@@ -888,16 +1253,17 @@ export class RoomStore {
888
1253
  this.prunePersonInbox(now);
889
1254
  // Person stubs have their own disposition/expiry horizon. Only the cap walk
890
1255
  // 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);
1256
+ 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);
1257
+ for (const row of expired) this.deleteSettledApplication(row, now);
893
1258
  for (;;) {
894
1259
  const identities = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM applications WHERE room=?").get(room)?.n ?? 0;
895
1260
  const aliases = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_frames WHERE room=?").get(room)?.n ?? 0;
896
1261
  if ((!freshIdentity || identities < ROOM_APPLICATION_RETENTION.identitiesPerRoom) && aliases < ROOM_APPLICATION_RETENTION.aliasesPerRoom) return;
1262
+ // 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
1263
  // 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 ?? "");
1264
+ 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
1265
  if (!settled) throw new RoomApplicationLimitError(room, Math.max(identities, aliases));
900
- this.deleteSettledApplication(settled);
1266
+ this.deleteSettledApplication(settled, now);
901
1267
  }
902
1268
  }
903
1269
  async applicationIncoming(name: string, digestValue: string, current?: () => boolean): Promise<{ sender: string; chat: RoomChatEnvelope; contentHash: string; primary: boolean; state: ApplicationState }> {
@@ -918,7 +1284,7 @@ export class RoomStore {
918
1284
  let saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key);
919
1285
  if (saved && saved.settled_at !== null && saved.settled_at <= now - ROOM_APPLICATION_RETENTION.settledMs && isSettledApplication(saved.state)) {
920
1286
  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; }
1287
+ if (!this.personIdentityRetained(saved)) { this.deleteSettledApplication(saved); saved = null; }
922
1288
  }
923
1289
  if (saved && (saved.content_hash !== contentHash || saved.peer !== chat.to)) fail("invalid");
924
1290
  const alias = this.applicationFor(state.current.room, digestValue);
@@ -1152,8 +1518,14 @@ export class RoomStore {
1152
1518
  return row.hop!;
1153
1519
  }
1154
1520
  private recoverApplications(): void {
1155
- this.transaction(() => {
1156
- this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted')").run();
1521
+ this.transaction(now => {
1522
+ 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);
1523
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted') AND expires_at<=?").run(now);
1524
+ 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();
1525
+ for (const row of lateRows) if (this.lateReceiptProof(row) === "valid") {
1526
+ 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);
1527
+ }
1528
+ 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
1529
  this.db.query("UPDATE applications SET state='needs_review' WHERE state='dispatching'").run();
1158
1530
  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
1531
  for (const state of this.rooms.values()) this.pruneInbox(state.current.room);