@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,1210 @@
1
+ /** Machine-local X3 room identity, epoch pins and durable crypto ports. No network or bus effects. */
2
+ import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Database } from "bun:sqlite";
5
+ import { realPath } from "../hub/project.ts";
6
+ import { MAX_HOP, USER } from "../hub/envelope.ts";
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";
9
+ import { decodeRoomMessage, encodeRoomMessage, type RoomChatEnvelope, type RoomMessage, type RoomReceiptReason, parseRemotePeerId } from "./application.ts";
10
+
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 });
12
+ export type RoomStoreCode = "invalid" | "busy" | "corrupt" | "unsupported" | "limit" | "untrusted" | "state_changed" | "clock_rollback" | "closed" | "inbox_list_limit";
13
+ export class RoomStoreError extends Error {
14
+ constructor(readonly code: RoomStoreCode) { super(`Room store: ${code}`); this.name = "RoomStoreError"; }
15
+ }
16
+ /** Fresh crypto receive reached a physical cap with a canonical receipt that has no outstanding target. */
17
+ export class RoomUnmatchedReceiptError extends RoomStoreError {
18
+ constructor() { super("limit"); this.name = "RoomUnmatchedReceiptError"; }
19
+ }
20
+ /** A fresh authenticated chat reached the ordinary physical cap before any replay or admission write. */
21
+ export class RoomFreshChatCapacityError extends RoomStoreError {
22
+ constructor() { super("limit"); this.name = "RoomFreshChatCapacityError"; }
23
+ }
24
+ export class RoomApplicationLimitError extends RoomStoreError {
25
+ constructor(readonly room: string, readonly count: number) {
26
+ super("limit"); this.name = "RoomApplicationLimitError";
27
+ this.message = `room application cap reached: room ${room}, count ${count}`;
28
+ }
29
+ }
30
+ function fail(code: RoomStoreCode): never { throw new RoomStoreError(code); }
31
+ const DB_NAME = "rooms.db", SCHEMA = 4, GRACE_MS = 60_000;
32
+ const ROOM_DB_BUSY_TIMEOUT_MS = 1;
33
+ // ponytail: keep at most 256 historical sender floors per room; refuse new senders at the ceiling until versioned archival preserves replay proofs.
34
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/;
35
+ interface Row { name: string; room: string; broker: string; owner: string; epoch: string; digest: string; wire: Uint8Array; previous_wire: Uint8Array | null; previous_digest: string | null; previous_epoch: string | null; previous_expires: number | null; left: number }
36
+ interface InviteRow { name: string; id: string; wire: Uint8Array; digest: string; expires: number; request_digest: string | null }
37
+ export interface RoomState { name: string; broker: string; current: VerifiedEpoch; wire: Uint8Array; previous?: { epoch: VerifiedEpoch; expiresAt: number }; left: boolean }
38
+ export interface RoomStateMetadata { name: string; room: string; owner: string; member: string; epoch: string; members: number; left: boolean }
39
+ export interface RoomInboxEntry { sender: string; seq: string; digest: string; plaintext: Uint8Array; gap: string }
40
+ export interface RoomPersonInboxMeta {
41
+ id: string; name: string; room: string; sender: string; from: string; receivedAt: number; expiresAt: number; bytes: number;
42
+ state: "unread" | "done" | "discarded" | "expired";
43
+ }
44
+ export interface RoomPersonInboxItem extends RoomPersonInboxMeta { body?: string }
45
+ export interface RoomPersonInboxAdmission { state: "admitted" | "duplicate" | "refused"; reason: RoomReceiptReason; id?: string }
46
+ interface PersonInboxRow {
47
+ id: string; room: string; sender: string; application: string; primary_digest: string; from_peer: string;
48
+ received_at: number; expires_at: number; bytes: number; body: Uint8Array | null;
49
+ state: RoomPersonInboxMeta["state"]; settled_at: number | null;
50
+ }
51
+ export const ROOM_PERSON_INBOX_LIMITS = Object.freeze({ unreadPerRoom: 100, bodyBytes: 65536, globalBytes: 4 * 1024 * 1024, stubsPerRoom: 1000, ttlMs: 30 * 24 * 60 * 60 * 1000 });
52
+ export interface RoomOutgoingInput { deliveryId: string; application: string; remotePeer: string; recipient: string; peer: string; ackTimeoutMs: number; ttlMs: number }
53
+ export interface RoomOutgoing {
54
+ digest: string; deliveryId: string; application: string; remotePeer: string; recipient: string; peer: string;
55
+ createdAt: number; expiresAt: number; ackDeadline?: number;
56
+ state: "dispatching" | "accepted" | "completed" | "failed_safe" | "needs_review";
57
+ disposition?: "completed" | "retry" | "discard";
58
+ reason?: RoomReceiptReason; lateReceipt?: { outcome: "completed" | "failed_safe"; reason: RoomReceiptReason };
59
+ }
60
+ type ApplicationState = "pending" | "dispatching" | "completed" | "failed_safe" | "needs_review" | "disposed";
61
+ 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
+ export interface RoomIncomingApplication { sender: string; application: string; peer: string; state: ApplicationState; digest: string; disposition?: "accept" | "discard"; disposedAt?: number; disposedBy?: "user" }
63
+ interface OutgoingRow {
64
+ room: string; digest: string; delivery_id: string; application: string; remote_peer: string; recipient: string; peer: string;
65
+ created_at: number; expires_at: number; ack_timeout: number; ack_deadline: number | null; state: RoomOutgoing["state"];
66
+ reason: RoomReceiptReason | null; late_outcome: "completed" | "failed_safe" | null; late_reason: RoomReceiptReason | null; receipt_digest: string | null;
67
+ disposition: "completed" | "retry" | "discard" | null;
68
+ }
69
+ 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"];
70
+ const APPLICATION_HISTORY_CAP = 1000;
71
+ export const ROOM_APPLICATION_RETENTION = Object.freeze({ identitiesPerRoom: 10_000, aliasesPerRoom: 10_000, settledMs: 30 * 24 * 60 * 60 * 1000 });
72
+ const isTerminal = (state: string): boolean => state === "completed" || state === "failed_safe";
73
+ const isSettledApplication = (state: string): boolean => isTerminal(state) || state === "disposed";
74
+ function validOutcome(outcome: unknown, reason: unknown, peer?: string): boolean {
75
+ if (!APPLICATION_REASONS.includes(reason as RoomReceiptReason)) return false;
76
+ if (outcome === "completed") return reason === "inbox_admitted" ? peer === undefined || peer === USER : reason === "admitted" && peer !== USER;
77
+ return outcome === "failed_safe" && reason !== "admitted" && reason !== "inbox_admitted"
78
+ && (!(typeof reason === "string" && reason.startsWith("inbox_")) || peer === undefined || peer === USER);
79
+ }
80
+ function boundedId(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s|]/.test(value); }
81
+ 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
+ function outgoing(row: OutgoingRow): RoomOutgoing {
83
+ 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,
85
+ ...(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
+ }
87
+ function incomingMetadata(row: ApplicationRow, digestValue = row.primary_digest): RoomIncomingApplication {
88
+ return { sender: row.sender, application: row.application, peer: row.peer, state: row.state, digest: digestValue,
89
+ ...(row.disposition !== null ? { disposition: row.disposition, disposedAt: row.disposed_at!, disposedBy: row.disposed_by! } : {}) };
90
+ }
91
+ const ROOM_REFUSAL_REASONS = ["invalid_presence", "wrong_epoch", "trust_changed", "stale", "invalid_frame"] as const;
92
+ export type RoomRefusalReason = typeof ROOM_REFUSAL_REASONS[number];
93
+ export interface RoomRefusal { digest: string; reason: RoomRefusalReason; at: number }
94
+ function validDigest(value: unknown): value is string {
95
+ if (typeof value !== "string" || value.length !== 43) return false;
96
+ try { return encodeBase64url(decodeBase64url(value, 32)) === value; } catch { return false; }
97
+ }
98
+ function code(error: unknown): RoomStoreCode {
99
+ const value = String(error).toLowerCase();
100
+ return value.includes("locked") || value.includes("busy") ? "busy" : "corrupt";
101
+ }
102
+ function named(value: string): string { if (!NAME.test(value)) fail("invalid"); return value; }
103
+ function assertCurrent(current?: () => boolean): void {
104
+ if (!current) return;
105
+ try { if (current() === true) return; } catch { /* host fence is fail-closed */ }
106
+ fail("state_changed");
107
+ }
108
+ const copy = (wire: Uint8Array): Uint8Array => Uint8Array.from(wire);
109
+ const digest = async (wire: Uint8Array) => encodeBase64url(await sha256(wire));
110
+
111
+ function validSelection(value: unknown): boolean {
112
+ if (value === undefined || value === null) return true;
113
+ if (typeof value !== "string" || value.length > 4096) return false;
114
+ try {
115
+ const selected: unknown = JSON.parse(value);
116
+ if (!selected || typeof selected !== "object" || Array.isArray(selected)) return false;
117
+ const row = selected as Record<string, unknown>;
118
+ if (Object.keys(row).sort().join(",") !== "label,peers" || typeof row.label !== "string" || !NAME.test(row.label)
119
+ || !Array.isArray(row.peers) || row.peers.length > 64) return false;
120
+ const peers: unknown[] = row.peers;
121
+ return peers.every((peer, index) => typeof peer === "string" && NAME.test(peer) && (index === 0 || String(peers[index - 1]) < peer));
122
+ } catch { return false; }
123
+ }
124
+
125
+ export class RoomStore {
126
+ readonly sequence: SequencePort;
127
+ readonly replay: ReplayPort;
128
+ private readonly rooms = new Map<string, RoomState>();
129
+ private closed = false;
130
+ private pendingPersonPurge = false;
131
+ private constructor(private readonly db: Database, readonly identity: HubIdentity, private readonly clock: () => number) {
132
+ this.sequence = { reserve: async input => this.reserve(input), isCurrent: input => this.isCurrent(input) };
133
+ this.replay = { accept: async input => this.accept(input) };
134
+ }
135
+
136
+ static async open(input: { stateDir: string; clock?: () => number }): Promise<RoomStore> {
137
+ await assertRoomCryptoReady();
138
+ const clock = input.clock ?? Date.now;
139
+ let db: Database | undefined;
140
+ try {
141
+ if (existsSync(input.stateDir)) {
142
+ const st = lstatSync(input.stateDir);
143
+ if (!st.isDirectory() || st.isSymbolicLink()) fail("invalid");
144
+ const entries = readdirSync(input.stateDir);
145
+ if (entries.some(x => ![DB_NAME, `${DB_NAME}-journal`].includes(x)) || (entries.length && !entries.includes(DB_NAME))) fail("invalid");
146
+ if ((st.mode & 0o777) !== 0o700 && entries.length) fail("invalid");
147
+ } else mkdirSync(input.stateDir, { recursive: true, mode: 0o700 });
148
+ chmodSync(input.stateDir, 0o700);
149
+ const file = join(realPath(input.stateDir), DB_NAME);
150
+ let created = false;
151
+ if (!existsSync(file)) {
152
+ try { const fd = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); closeSync(fd); created = true; }
153
+ catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; }
154
+ }
155
+ const st = lstatSync(file);
156
+ if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600) fail("invalid");
157
+ db = new Database(file, { strict: true });
158
+ db.run(`PRAGMA busy_timeout = ${ROOM_DB_BUSY_TIMEOUT_MS}`);
159
+ db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;");
160
+ 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");
162
+ if (version === 0) {
163
+ if (!created) fail("corrupt");
164
+ db.exec(`
165
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID;
166
+ CREATE TABLE rooms (name TEXT PRIMARY KEY, room TEXT NOT NULL UNIQUE, broker TEXT NOT NULL, owner TEXT NOT NULL, epoch TEXT NOT NULL, digest TEXT NOT NULL, wire BLOB NOT NULL, previous_wire BLOB, previous_digest TEXT, previous_epoch TEXT, previous_expires INTEGER, left INTEGER NOT NULL DEFAULT 0) WITHOUT ROWID;
167
+ CREATE TABLE counters (room TEXT PRIMARY KEY, seq TEXT NOT NULL) WITHOUT ROWID;
168
+ CREATE TABLE floors (room TEXT NOT NULL, sender TEXT NOT NULL, seq TEXT NOT NULL, digest TEXT NOT NULL, PRIMARY KEY(room,sender)) WITHOUT ROWID;
169
+ CREATE TABLE inbox (room TEXT NOT NULL, sender TEXT NOT NULL, seq TEXT NOT NULL, digest TEXT NOT NULL, plaintext BLOB, gap TEXT NOT NULL, UNIQUE(room,sender,seq));
170
+ CREATE INDEX inbox_room ON inbox(room);
171
+ CREATE TABLE invites (name TEXT NOT NULL, id TEXT PRIMARY KEY, wire BLOB NOT NULL, digest TEXT NOT NULL, expires INTEGER NOT NULL, request_digest TEXT) WITHOUT ROWID;
172
+ CREATE TABLE outbox (room TEXT NOT NULL, digest TEXT PRIMARY KEY, wire BLOB NOT NULL, roster BLOB NOT NULL, state TEXT NOT NULL);
173
+ CREATE TABLE pending_joins (name TEXT PRIMARY KEY, invite BLOB NOT NULL, request BLOB NOT NULL, selection TEXT) WITHOUT ROWID;
174
+ CREATE TABLE refusals (room TEXT NOT NULL, digest TEXT NOT NULL, reason TEXT NOT NULL, at INTEGER NOT NULL, UNIQUE(room,digest));
175
+ PRAGMA user_version=1;
176
+ `);
177
+ }
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(",");
179
+ 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");
181
+ const columns: Record<string, string[]> = {
182
+ meta: ["key", "value"],
183
+ rooms: ["name", "room", "broker", "owner", "epoch", "digest", "wire", "previous_wire", "previous_digest", "previous_epoch", "previous_expires", "left"],
184
+ counters: ["room", "seq"], floors: ["room", "sender", "seq", "digest"],
185
+ inbox: ["room", "sender", "seq", "digest", "plaintext", "gap", ...(version! >= 3 ? ["receipt_reserve"] : []), ...(version === 4 ? ["received_at"] : [])],
186
+ invites: ["name", "id", "wire", "digest", "expires", "request_digest"],
187
+ outbox: ["room", "digest", "wire", "roster", "state"], pending_joins: ["name", "invite", "request", "selection"], refusals: ["room", "digest", "reason", "at"],
188
+ };
189
+ for (const [table, names] of Object.entries(columns)) {
190
+ if (db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all().map(x => x.name).join(",") !== names.join(",")) fail("unsupported");
191
+ }
192
+ if (version! < 2) db.exec(`
193
+ CREATE TABLE application_outbox (room TEXT NOT NULL, digest TEXT PRIMARY KEY, delivery_id TEXT NOT NULL, application TEXT NOT NULL, remote_peer TEXT NOT NULL, recipient TEXT NOT NULL, peer TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, ack_timeout INTEGER NOT NULL, ack_deadline INTEGER, state TEXT NOT NULL, reason TEXT, late_outcome TEXT, late_reason TEXT, receipt_digest TEXT, disposition TEXT);
194
+ CREATE INDEX application_outbox_room ON application_outbox(room);
195
+ CREATE UNIQUE INDEX application_outbox_delivery ON application_outbox(room,delivery_id);
196
+ CREATE TABLE applications (room TEXT NOT NULL, sender TEXT NOT NULL, application TEXT NOT NULL, content_hash TEXT NOT NULL, primary_digest TEXT NOT NULL, peer TEXT NOT NULL, trace TEXT NOT NULL, hop INTEGER NOT NULL, state TEXT NOT NULL, reason TEXT, created_at INTEGER NOT NULL, settled_at INTEGER, disposition TEXT, disposed_at INTEGER, disposed_by TEXT, PRIMARY KEY(room,sender,application)) WITHOUT ROWID;
197
+ CREATE INDEX applications_retention ON applications(room,settled_at);
198
+ CREATE INDEX applications_trace ON applications(trace,hop);
199
+ CREATE INDEX applications_status ON applications(room,state,created_at,primary_digest);
200
+ CREATE INDEX applications_disposition ON applications(room,state,disposed_at);
201
+ CREATE TABLE application_frames (room TEXT NOT NULL, digest TEXT NOT NULL, sender TEXT NOT NULL, application TEXT NOT NULL, PRIMARY KEY(room,digest)) WITHOUT ROWID;
202
+ PRAGMA user_version=2;
203
+ `);
204
+ 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"],
206
+ applications: ["room", "sender", "application", "content_hash", "primary_digest", "peer", "trace", "hop", "state", "reason", "created_at", "settled_at", "disposition", "disposed_at", "disposed_by"],
207
+ application_frames: ["room", "digest", "sender", "application"],
208
+ };
209
+ for (const [table, names] of Object.entries(applicationColumns)) {
210
+ if (db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all().map(x => x.name).join(",") !== names.join(",")) fail("unsupported");
211
+ }
212
+ for (const [index, names] of Object.entries({ application_outbox_room: ["room"], application_outbox_delivery: ["room", "delivery_id"], applications_retention: ["room", "settled_at"], applications_trace: ["trace", "hop"], applications_status: ["room", "state", "created_at", "primary_digest"], applications_disposition: ["room", "state", "disposed_at"] })) {
213
+ if (db.query<{ name: string }, []>(`PRAGMA index_info(${index})`).all().map(x => x.name).join(",") !== names.join(",")) fail("unsupported");
214
+ }
215
+ if (db.query<{ name: string; unique: number }, []>("PRAGMA index_list(application_outbox)").all().find(row => row.name === "application_outbox_delivery")?.unique !== 1) fail("unsupported");
216
+ if (version! < 3) {
217
+ // Legacy readers must refuse the extra lane. Legacy payload JSON never manufactures a reserved admission.
218
+ if (db.query("SELECT 1 FROM application_outbox WHERE typeof(created_at)!='integer' OR typeof(ack_timeout)!='integer' OR (ack_deadline IS NOT NULL AND (typeof(ack_deadline)!='integer' OR ack_deadline<created_at)) LIMIT 1").get()) fail("corrupt");
219
+ db.exec("UPDATE application_outbox SET ack_deadline=CASE WHEN ack_deadline IS NULL THEN created_at+ack_timeout ELSE MIN(ack_deadline,created_at+ack_timeout) END;");
220
+ db.exec("ALTER TABLE inbox ADD COLUMN receipt_reserve INTEGER NOT NULL DEFAULT 0 CHECK (receipt_reserve IN (0,1)); PRAGMA user_version=3;");
221
+ }
222
+ if (version! < 4) db.exec(`
223
+ ALTER TABLE inbox ADD COLUMN received_at INTEGER;
224
+ CREATE TABLE person_inbox (id TEXT PRIMARY KEY, room TEXT NOT NULL, sender TEXT NOT NULL, application TEXT NOT NULL, primary_digest TEXT NOT NULL, from_peer TEXT NOT NULL, received_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, bytes INTEGER NOT NULL, body BLOB, state TEXT NOT NULL, settled_at INTEGER);
225
+ CREATE UNIQUE INDEX person_inbox_identity ON person_inbox(room,sender,application);
226
+ CREATE INDEX person_inbox_room ON person_inbox(room,state,received_at,id);
227
+ CREATE INDEX person_inbox_retention ON person_inbox(state,settled_at);
228
+ PRAGMA user_version=4;
229
+ `);
230
+ const personColumns = ["id", "room", "sender", "application", "primary_digest", "from_peer", "received_at", "expires_at", "bytes", "body", "state", "settled_at"];
231
+ if (db.query<{ name: string }, []>("PRAGMA table_info(person_inbox)").all().map(row => row.name).join(",") !== personColumns.join(",")) fail("unsupported");
232
+ 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"] })) {
233
+ if (db.query<{ name: string }, []>(`PRAGMA index_info(${index})`).all().map(row => row.name).join(",") !== fields.join(",")) fail("unsupported");
234
+ }
235
+ if (db.query<{ name: string; unique: number }, []>("PRAGMA index_list(person_inbox)").all().find(row => row.name === "person_inbox_identity")?.unique !== 1) fail("unsupported");
236
+ if (db.query<{ quick_check: string }, []>("PRAGMA quick_check").all().some(x => x.quick_check !== "ok")) fail("corrupt");
237
+ const meta = db.query<{ key: string; n: number }, []>("SELECT key,length(value) AS n FROM meta LIMIT 4").all();
238
+ if (meta.length > 2 || meta.some(row => !["identity", "clock"].includes(row.key) || row.n > (row.key === "identity" ? 4096 : 32))) fail("corrupt");
239
+ if (!created && (!meta.some(row => row.key === "identity") || !meta.some(row => row.key === "clock"))) fail("corrupt");
240
+ let identity: HubIdentity;
241
+ const stored = db.query<{ value: string }, []>("SELECT value FROM meta WHERE key='identity'").get();
242
+ if (stored) identity = await importHubIdentity(JSON.parse(stored.value) as LocalIdentityRecord);
243
+ else {
244
+ if (!created) fail("corrupt");
245
+ identity = await generateHubIdentity();
246
+ db.query("INSERT INTO meta(key,value) VALUES('identity',?)").run(JSON.stringify(await exportHubIdentityForStorage(identity)));
247
+ }
248
+ db.exec("COMMIT;");
249
+ const store = new RoomStore(db, identity, clock);
250
+ store.transaction(() => undefined);
251
+ if ((db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM rooms").get()?.n ?? 0) > ROOM_STORE_LIMITS.rooms
252
+ || db.query("SELECT 1 FROM rooms WHERE length(name)>32 OR length(room)>43 OR length(broker)>2048 OR length(owner)>64 OR length(epoch)>20 OR length(digest)>43 OR length(previous_epoch)>20 OR length(previous_digest)>43 OR length(wire)>524288 OR length(previous_wire)>524288 LIMIT 1").get()) fail("corrupt");
253
+ const rows = db.query<Row, []>("SELECT * FROM rooms ORDER BY name LIMIT 65").all();
254
+ if (rows.length > ROOM_STORE_LIMITS.rooms) fail("corrupt");
255
+ for (const row of rows) {
256
+ named(row.name);
257
+ if (!(row.wire instanceof Uint8Array) || row.wire.length > 524288 || brokerUrl(row.broker) !== row.broker || ![0, 1].includes(row.left)) fail("corrupt");
258
+ const current = await acceptRoomEpoch({ wire: row.wire, identity, pin: { room: row.room, owner: row.owner, minEpoch: row.epoch, digest: row.digest } });
259
+ let previous: RoomState["previous"];
260
+ const fields = [row.previous_wire, row.previous_digest, row.previous_epoch, row.previous_expires];
261
+ if (fields.some(x => x !== null)) {
262
+ if (fields.some(x => x === null) || !Number.isSafeInteger(row.previous_expires) || row.previous_expires! <= 0 || row.previous_expires! > store.now() + GRACE_MS || BigInt(row.previous_epoch!) + 1n !== BigInt(row.epoch)) fail("corrupt");
263
+ const epoch = await acceptRoomEpoch({ wire: row.previous_wire!, identity, pin: { room: row.room, owner: row.owner, minEpoch: row.previous_epoch!, digest: row.previous_digest! } });
264
+ previous = { epoch, expiresAt: row.previous_expires! };
265
+ }
266
+ store.rooms.set(row.name, { name: row.name, broker: row.broker, current, wire: copy(row.wire), ...(previous ? { previous } : {}), left: row.left === 1 });
267
+ }
268
+ try { await store.validateRows(); }
269
+ catch { fail("corrupt"); }
270
+ store.recoverApplications();
271
+ store.expirePersonInbox();
272
+ return store;
273
+ } catch (e) {
274
+ try { db?.exec("ROLLBACK;"); } catch { /* no transaction */ }
275
+ try { db?.close(); } catch { /* preserve fixed refusal */ }
276
+ if (e instanceof RoomStoreError) throw e;
277
+ throw new RoomStoreError(code(e));
278
+ }
279
+ }
280
+
281
+ /** Restore validates bounded rows before any sequence, replay or transport port is published. */
282
+ private async validateRows(): Promise<void> {
283
+ const limits = ROOM_STORE_LIMITS;
284
+ const cap = (table: string, maximum: number) => {
285
+ const count = this.db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0;
286
+ if (!Number.isSafeInteger(count) || count > maximum) fail("corrupt");
287
+ };
288
+ cap("counters", limits.rooms); cap("floors", limits.rooms * limits.floorsPerRoom);
289
+ cap("inbox", limits.rooms * (limits.receiptsPerRoom + limits.inboxPerRoom + limits.receiptReservePerRoom));
290
+ cap("outbox", limits.rooms * limits.outboxPerRoom); cap("invites", limits.invites); cap("pending_joins", limits.pendingJoins); cap("refusals", limits.rooms * limits.refusedPerRoom);
291
+ const groupCap = (table: string, filter: string, maximum: number) => {
292
+ if (this.db.query<{ room: string }, [number]>(`SELECT room FROM ${table} ${filter} GROUP BY room HAVING COUNT(*)>? LIMIT 1`).get(maximum)) fail("corrupt");
293
+ };
294
+ groupCap("floors", "", limits.floorsPerRoom);
295
+ groupCap("inbox", "WHERE plaintext IS NOT NULL AND receipt_reserve=0", limits.inboxPerRoom);
296
+ groupCap("inbox", "WHERE plaintext IS NOT NULL AND receipt_reserve=1", limits.receiptReservePerRoom);
297
+ groupCap("inbox", "WHERE plaintext IS NULL", limits.receiptsPerRoom);
298
+ groupCap("outbox", "", limits.outboxPerRoom);
299
+ groupCap("refusals", "", limits.refusedPerRoom);
300
+ const metadataBounds: Record<string, string> = {
301
+ counters: "length(room)>43 OR length(seq)>20",
302
+ floors: "length(room)>43 OR length(sender)>64 OR length(seq)>20 OR length(digest)>43",
303
+ inbox: "length(room)>43 OR length(sender)>64 OR length(seq)>20 OR length(digest)>43 OR length(gap)>20",
304
+ invites: "length(name)>32 OR length(id)>43 OR length(digest)>43 OR length(request_digest)>43",
305
+ outbox: "length(room)>43 OR length(digest)>43 OR length(state)>16",
306
+ pending_joins: "length(name)>32",
307
+ refusals: "length(room)>43 OR length(digest)>43 OR length(reason)>32",
308
+ };
309
+ for (const [table, condition] of Object.entries(metadataBounds)) {
310
+ if (this.db.query(`SELECT 1 FROM ${table} WHERE ${condition} LIMIT 1`).get()) fail("corrupt");
311
+ }
312
+ const blob = (value: unknown, maximum: number): Uint8Array => {
313
+ if (!(value instanceof Uint8Array) || value.byteLength > maximum || value.byteLength === 0) fail("corrupt");
314
+ return value;
315
+ };
316
+ const fp = (value: unknown): string => {
317
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) fail("corrupt");
318
+ return value;
319
+ };
320
+ const hash = (value: unknown): string => {
321
+ if (typeof value !== "string" || encodeBase64url(decodeBase64url(value, 32)) !== value) fail("corrupt");
322
+ return value;
323
+ };
324
+ const room = (value: string): RoomState => this.byRoom(value) ?? fail("corrupt");
325
+ const bodyBytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(plaintext)),0) AS n FROM inbox WHERE receipt_reserve=0").get()?.n ?? 0;
326
+ const reservedBytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(plaintext)),0) AS n FROM inbox WHERE receipt_reserve=1").get()?.n ?? 0;
327
+ const outboxBytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
328
+ if (bodyBytes > limits.inboxBytes || reservedBytes > limits.rooms * limits.receiptReservePerRoom * limits.receiptReserveBytes || outboxBytes > limits.outboxBytes) fail("corrupt");
329
+ if (this.db.query("SELECT 1 FROM inbox WHERE typeof(receipt_reserve)!='integer' OR receipt_reserve NOT IN (0,1) OR length(plaintext)>65536 OR (receipt_reserve=1 AND length(plaintext)>1024) LIMIT 1").get()
330
+ || this.db.query("SELECT 1 FROM outbox WHERE length(wire)>131072 OR length(roster)>524288 LIMIT 1").get()
331
+ || this.db.query("SELECT 1 FROM invites WHERE length(wire)>16384 LIMIT 1").get()
332
+ || this.db.query("SELECT 1 FROM pending_joins WHERE length(invite)>16384 OR length(request)>16384 OR length(selection)>4096 LIMIT 1").get()) fail("corrupt");
333
+ const counters = new Map<string, string>();
334
+ for (const row of this.db.query<{ room: string; seq: string }, []>("SELECT room,seq FROM counters LIMIT 65").all()) {
335
+ room(row.room); counters.set(row.room, positiveU64(row.seq));
336
+ }
337
+ const floors = new Map<string, { seq: string; digest: string }>();
338
+ for (const row of this.db.query<{ room: string; sender: string; seq: string; digest: string }, []>(`SELECT room,sender,seq,digest FROM floors LIMIT ${limits.rooms * limits.floorsPerRoom + 1}`).all()) {
339
+ room(row.room); fp(row.sender); positiveU64(row.seq); hash(row.digest);
340
+ // Removed senders keep their floor. Current membership is checked at acceptance, not retroactively imposed on history.
341
+ floors.set(`${row.room}|${row.sender}`, { seq: row.seq, digest: row.digest });
342
+ }
343
+ for (const row of this.db.query<{ room: string; sender: string; seq: string; digest: string; plaintext: Uint8Array | null; gap: string; receipt_reserve: number; received_at: number | null }, []>(`SELECT room,sender,seq,digest,plaintext,gap,receipt_reserve,received_at FROM inbox LIMIT ${limits.rooms * (limits.receiptsPerRoom + limits.inboxPerRoom + limits.receiptReservePerRoom) + 1}`).all()) {
344
+ room(row.room); fp(row.sender); positiveU64(row.seq); hash(row.digest);
345
+ if (row.received_at !== null && (!Number.isSafeInteger(row.received_at) || row.received_at <= 0 || row.received_at > this.now())) fail("corrupt");
346
+ if (typeof row.gap !== "string" || !/^(0|[1-9][0-9]{0,19})$/.test(row.gap) || BigInt(row.gap) >= BigInt(row.seq)) fail("corrupt");
347
+ if (row.plaintext !== null && (!(row.plaintext instanceof Uint8Array) || row.plaintext.length > 65536)) fail("corrupt");
348
+ // This private host-issued marker is preserved across completion; never reconstruct it from JSON or current outbox state.
349
+ if (row.receipt_reserve === 1 && row.plaintext !== null && decodeRoomMessage(row.plaintext).type !== "receipt") fail("corrupt");
350
+ const floor = floors.get(`${row.room}|${row.sender}`);
351
+ if (!floor || BigInt(floor.seq) < BigInt(row.seq) || (floor.seq === row.seq && floor.digest !== row.digest)) fail("corrupt");
352
+ }
353
+ const proofs = new Map<string, VerifiedPublicRoster>();
354
+ for (const row of this.db.query<{ room: string; digest: string; wire: Uint8Array; roster: Uint8Array; state: string }, []>(`SELECT room,digest,wire,roster,state FROM outbox LIMIT ${limits.rooms * limits.outboxPerRoom + 1}`).all()) {
355
+ const installed = room(row.room), own = this.identity.public;
356
+ hash(row.digest); blob(row.wire, 131072); blob(row.roster, 524288);
357
+ if (!["dispatching", "accepted"].includes(row.state)) fail("corrupt");
358
+ const rosterHash = await digest(row.roster);
359
+ let proof = proofs.get(rosterHash);
360
+ if (!proof) {
361
+ proof = await verifyPublicRoster({ wire: row.roster, pin: { room: row.room, owner: decodeRoster(installed.wire).owner, minEpoch: "0" } });
362
+ proofs.set(rosterHash, proof);
363
+ }
364
+ if (proof.room !== row.room || BigInt(proof.epoch) > BigInt(installed.current.epoch)
365
+ || (proof.epoch === installed.current.epoch && proof.digest !== installed.current.digest)
366
+ || (proof.epoch === installed.previous?.epoch.epoch && proof.digest !== installed.previous.epoch.digest)) fail("corrupt");
367
+ const sender = proof.members.find(member => member.fingerprint === own.fingerprint);
368
+ if (!sender || sender.signingKey !== own.signingKey || sender.agreementKey !== own.agreementKey) fail("corrupt");
369
+ const frame = await verifyPublicFrame({ wire: row.wire, roster: proof });
370
+ const reserved = counters.get(row.room);
371
+ if (frame.digest !== row.digest || frame.sender !== own.fingerprint || !reserved || BigInt(frame.seq) > BigInt(reserved)) fail("corrupt");
372
+ }
373
+ for (const row of this.db.query<InviteRow, []>(`SELECT * FROM invites LIMIT ${limits.invites + 1}`).all()) {
374
+ const installed = this.rooms.get(row.name); if (!installed) fail("corrupt");
375
+ const invite = decodeInvite(blob(row.wire, 16384));
376
+ if (invite.owner !== this.identity.public.fingerprint || invite.owner !== decodeRoster(installed.wire).owner
377
+ || invite.room !== installed.current.room || invite.broker !== installed.broker || invite.inviteId !== row.id || invite.expiresAt !== row.expires || await digest(row.wire) !== hash(row.digest)) fail("corrupt");
378
+ if (row.request_digest !== null) hash(row.request_digest);
379
+ }
380
+ for (const row of this.db.query<{ room: string; digest: string; reason: RoomRefusalReason; at: number }, []>(`SELECT room,digest,reason,at FROM refusals LIMIT ${limits.rooms * limits.refusedPerRoom + 1}`).all()) {
381
+ room(row.room); hash(row.digest);
382
+ if (!ROOM_REFUSAL_REASONS.includes(row.reason) || !Number.isSafeInteger(row.at) || row.at <= 0 || row.at > this.now()) fail("corrupt");
383
+ }
384
+ for (const row of this.db.query<{ name: string; invite: Uint8Array; request: Uint8Array; selection: string | null }, []>(`SELECT * FROM pending_joins LIMIT ${limits.pendingJoins + 1}`).all()) {
385
+ named(row.name); if (this.rooms.has(row.name)) fail("corrupt");
386
+ const invite = decodeInvite(blob(row.invite, 16384)), request = decodeJoin(blob(row.request, 16384)), own = this.identity.public;
387
+ if (request.room !== invite.room || request.owner !== invite.owner || request.inviteId !== invite.inviteId
388
+ || request.member.fingerprint !== own.fingerprint || request.member.signingKey !== own.signingKey || request.member.agreementKey !== own.agreementKey) fail("corrupt");
389
+ if (!validSelection(row.selection)) fail("corrupt");
390
+ }
391
+ await this.validateApplications();
392
+ await this.validatePersonInbox();
393
+ }
394
+
395
+ private async validateApplications(): Promise<void> {
396
+ const maximum = ROOM_STORE_LIMITS.rooms, now = this.now();
397
+ const caps = { application_outbox: maximum * (ROOM_STORE_LIMITS.outboxPerRoom + APPLICATION_HISTORY_CAP), applications: maximum * ROOM_APPLICATION_RETENTION.identitiesPerRoom, application_frames: maximum * ROOM_APPLICATION_RETENTION.aliasesPerRoom };
398
+ for (const [table, cap] of Object.entries(caps)) {
399
+ const count = this.db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0;
400
+ if (!Number.isSafeInteger(count) || count > cap) fail("corrupt");
401
+ }
402
+ for (const [table, maximumPerRoom] of [["application_outbox", ROOM_STORE_LIMITS.outboxPerRoom + APPLICATION_HISTORY_CAP], ["applications", ROOM_APPLICATION_RETENTION.identitiesPerRoom], ["application_frames", ROOM_APPLICATION_RETENTION.aliasesPerRoom]] as const) {
403
+ if (this.db.query<{ room: string }, [number]>(`SELECT room FROM ${table} GROUP BY room HAVING COUNT(*)>? LIMIT 1`).get(maximumPerRoom)) fail("corrupt");
404
+ }
405
+ 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
+ || 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()
408
+ || 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
+ || 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
+ const applications = new Map<string, ApplicationRow>();
411
+ for (const row of this.db.query<ApplicationRow, []>(`SELECT * FROM applications LIMIT ${caps.applications + 1}`).all()) {
412
+ if (!this.byRoom(row.room) || !/^[a-f0-9]{64}$/.test(row.sender) || !validUuid(row.application) || !validDigest(row.content_hash) || !validDigest(row.primary_digest) || !NAME.test(row.peer)
413
+ || !validUuid(row.trace) || !Number.isSafeInteger(row.hop) || row.hop < 0 || row.hop > MAX_HOP
414
+ || !["pending", "dispatching", "completed", "failed_safe", "needs_review", "disposed"].includes(row.state)
415
+ || !Number.isSafeInteger(row.created_at) || row.created_at <= 0 || row.created_at > now) fail("corrupt");
416
+ if (!this.db.query("SELECT 1 FROM floors WHERE room=? AND sender=?").get(row.room, row.sender)) fail("corrupt");
417
+ if (row.state === "disposed") {
418
+ if (!["accept", "discard"].includes(row.disposition!) || row.disposed_by !== "user" || row.reason !== null || !Number.isSafeInteger(row.disposed_at)
419
+ || row.disposed_at! < row.created_at || row.disposed_at! > now || row.settled_at !== row.disposed_at) fail("corrupt");
420
+ } else if (row.disposition !== null || row.disposed_at !== null || row.disposed_by !== null) fail("corrupt");
421
+ if (isTerminal(row.state)) {
422
+ if (!validOutcome(row.state, row.reason, row.peer) || !Number.isSafeInteger(row.settled_at) || row.settled_at! < row.created_at || row.settled_at! > now) fail("corrupt");
423
+ } else if (row.state !== "disposed" && (row.reason !== null || row.settled_at !== null)) fail("corrupt");
424
+ const entry = this.db.query<RoomInboxEntry, [string, string]>("SELECT sender,seq,digest,plaintext,gap FROM inbox WHERE room=? AND digest=?").get(row.room, row.primary_digest);
425
+ if (row.state === "pending" || row.state === "dispatching") {
426
+ if (!entry || entry.sender !== row.sender || !(entry.plaintext instanceof Uint8Array)) fail("corrupt");
427
+ const message = decodeRoomMessage(entry.plaintext);
428
+ if (message.type !== "chat" || message.envelope.id !== row.application || message.envelope.to !== row.peer || message.envelope.trace !== row.trace || message.envelope.hop !== row.hop || await digest(encodeRoomMessage(message)) !== row.content_hash) fail("corrupt");
429
+ } else if (entry && entry.plaintext !== null) fail("corrupt");
430
+ applications.set(`${row.room}|${row.sender}|${row.application}`, row);
431
+ }
432
+ const primary = new Set<string>();
433
+ for (const row of this.db.query<{ room: string; digest: string; sender: string; application: string }, []>(`SELECT * FROM application_frames LIMIT ${caps.application_frames + 1}`).all()) {
434
+ const app = applications.get(`${row.room}|${row.sender}|${row.application}`);
435
+ if (!app || !validDigest(row.digest)) fail("corrupt");
436
+ 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.digest);
437
+ if (entry && (entry.sender !== row.sender || (isSettledApplication(app.state) || app.state === "needs_review") && entry.plaintext !== null)) fail("corrupt");
438
+ if (app.primary_digest === row.digest) primary.add(`${row.room}|${row.sender}|${row.application}`);
439
+ }
440
+ if (primary.size !== applications.size) fail("corrupt");
441
+ for (const row of this.db.query<OutgoingRow, []>(`SELECT * FROM application_outbox LIMIT ${caps.application_outbox + 1}`).all()) {
442
+ if (!this.byRoom(row.room) || !validDigest(row.digest) || !boundedId(row.delivery_id) || !boundedId(row.application) || !NAME.test(row.peer)
443
+ || typeof row.remote_peer !== "string" || !new RegExp(`^${row.peer}@[a-z][a-z0-9-]{0,31}$`).test(row.remote_peer) || !/^[a-f0-9]{64}$/.test(row.recipient)
444
+ || !["dispatching", "accepted", "completed", "failed_safe", "needs_review"].includes(row.state)
445
+ || !Number.isSafeInteger(row.created_at) || row.created_at <= 0 || row.created_at > now || !Number.isSafeInteger(row.expires_at)
446
+ || 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");
452
+ 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");
457
+ const frame = decodeFrame(sealed.wire);
458
+ if (frame.recipients.length !== 1 || frame.recipients[0] !== row.recipient) fail("corrupt");
459
+ }
460
+ }
461
+ }
462
+
463
+ private async validatePersonInbox(): Promise<void> {
464
+ const limits = ROOM_PERSON_INBOX_LIMITS, now = this.now(), maximum = ROOM_STORE_LIMITS.rooms * (limits.unreadPerRoom + limits.stubsPerRoom);
465
+ const count = this.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM person_inbox").get()?.n ?? 0;
466
+ const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(body)),0) AS n FROM person_inbox WHERE body IS NOT NULL").get()?.n ?? 0;
467
+ if (!Number.isSafeInteger(count) || count > maximum || !Number.isSafeInteger(bytes) || bytes > limits.globalBytes
468
+ || this.db.query("SELECT room FROM person_inbox WHERE state='unread' GROUP BY room HAVING COUNT(*)>100 LIMIT 1").get()
469
+ || this.db.query("SELECT room FROM person_inbox WHERE state!='unread' GROUP BY room HAVING COUNT(*)>1000 LIMIT 1").get()
470
+ || this.db.query("SELECT 1 FROM person_inbox WHERE length(id)>36 OR length(room)>43 OR length(sender)>64 OR length(application)>36 OR length(primary_digest)>43 OR length(from_peer)>65 OR length(body)>65536 OR length(state)>9 LIMIT 1").get()) fail("corrupt");
471
+ const ids = new Set<string>(), keys = new Set<string>();
472
+ for (const row of this.db.query<PersonInboxRow, []>(`SELECT * FROM person_inbox LIMIT ${maximum + 1}`).all()) {
473
+ const room = this.byRoom(row.room), from = parseRemotePeerId(row.from_peer), key = `${row.room}|${row.sender}|${row.application}`;
474
+ if (!room || !validUuid(row.id) || ids.has(row.id) || keys.has(key) || !/^[a-f0-9]{64}$/.test(row.sender) || !validUuid(row.application) || !validDigest(row.primary_digest) || !from
475
+ || !Number.isSafeInteger(row.received_at) || row.received_at <= 0 || row.received_at > now || !Number.isSafeInteger(row.expires_at) || row.expires_at !== row.received_at + limits.ttlMs
476
+ || !Number.isSafeInteger(row.bytes) || row.bytes < 0 || row.bytes > limits.bodyBytes || !["unread", "done", "discarded", "expired"].includes(row.state)) fail("corrupt");
477
+ ids.add(row.id); keys.add(key);
478
+ const app = this.applicationFor(row.room, row.primary_digest);
479
+ if (!app || app.primary_digest !== row.primary_digest || app.sender !== row.sender || app.application !== row.application || app.peer !== USER || app.state !== "completed" || app.reason !== "inbox_admitted"
480
+ || row.received_at > app.created_at || app.settled_at === null || app.settled_at >= row.expires_at) fail("corrupt");
481
+ if (row.state === "unread") {
482
+ if (row.settled_at !== null || !(row.body instanceof Uint8Array)) fail("corrupt");
483
+ const message = decodeRoomMessage(row.body);
484
+ if (message.type !== "chat" || message.envelope.to !== USER || message.envelope.id !== app.application || message.envelope.from !== from.peer
485
+ || message.envelope.trace !== app.trace || message.envelope.hop !== app.hop || Buffer.byteLength(message.envelope.body, "utf8") !== row.bytes
486
+ || await digest(encodeRoomMessage(message)) !== app.content_hash) fail("corrupt");
487
+ } else if (row.body !== null || !Number.isSafeInteger(row.settled_at) || row.settled_at! < app.settled_at || row.settled_at! > now
488
+ || (row.state === "expired" ? row.settled_at !== row.expires_at : row.settled_at! >= row.expires_at)) fail("corrupt");
489
+ }
490
+ }
491
+
492
+ private assertOpen(): void { if (this.closed) fail("closed"); }
493
+ private now(): number {
494
+ this.assertOpen();
495
+ const now = this.clock();
496
+ const rawLast = this.db.query<{ value: string }, []>("SELECT value FROM meta WHERE key='clock'").get()?.value;
497
+ if (rawLast !== undefined && (typeof rawLast !== "string" || !/^[1-9][0-9]{0,15}$/.test(rawLast))) fail("corrupt");
498
+ const last = Number(rawLast ?? "0");
499
+ if (!Number.isSafeInteger(now) || now < 1 || !Number.isSafeInteger(last)) fail("invalid");
500
+ if (now < last) fail("clock_rollback");
501
+ return now;
502
+ }
503
+ private transaction<T>(fn: (now: number) => T): T {
504
+ this.assertOpen();
505
+ const priorPersonPurge = this.pendingPersonPurge;
506
+ try { return this.db.transaction(() => {
507
+ const now = this.now();
508
+ const result = fn(now);
509
+ this.db.query("INSERT INTO meta(key,value) VALUES('clock',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(now));
510
+ return result;
511
+ })(); } catch (e) {
512
+ this.pendingPersonPurge = priorPersonPurge; // Includes a successful nested savepoint rolled back by its outer transaction.
513
+ if (e instanceof RoomStoreError) throw e; throw new RoomStoreError(code(e));
514
+ }
515
+ }
516
+ private snapshot(value: RoomState): RoomState {
517
+ return { ...value, wire: copy(value.wire), ...(value.previous ? { previous: { ...value.previous } } : {}) };
518
+ }
519
+ private readonly statusEpochs = new WeakMap<VerifiedEpoch, { owner: string; members: number }>();
520
+ /** Loaded identity metadata only. Repeated polling never clones a wire or opens application history. */
521
+ statusMetadata(): RoomStateMetadata[] {
522
+ this.now();
523
+ return [...this.rooms.values()].map(value => {
524
+ let epoch = this.statusEpochs.get(value.current);
525
+ if (!epoch) {
526
+ epoch = { owner: decodeRoster(value.wire).owner, members: roomMembers(value.current).length };
527
+ this.statusEpochs.set(value.current, epoch);
528
+ }
529
+ return { name: value.name, room: value.current.room, owner: epoch.owner, member: this.identity.public.fingerprint,
530
+ epoch: value.current.epoch, members: epoch.members, left: value.left };
531
+ });
532
+ }
533
+ list(): RoomState[] { this.now(); return [...this.rooms.values()].map(x => this.snapshot(x)); }
534
+ get(name: string): RoomState | undefined { this.now(); const state = this.rooms.get(named(name)); return state && this.snapshot(state); }
535
+ private byRoom(room: string): RoomState | undefined { return [...this.rooms.values()].find(x => x.current.room === room); }
536
+
537
+ /** Pending enrollment is explicit local person authority; a roster can never establish its own owner pin. */
538
+ savePendingJoin(name: string, input: { invite: Uint8Array; request: Uint8Array; selection?: string }): void {
539
+ named(name); if (!validSelection(input.selection)) fail("invalid");
540
+ const invite = decodeInvite(input.invite), request = decodeJoin(input.request);
541
+ if (request.member.fingerprint !== this.identity.public.fingerprint || request.member.signingKey !== this.identity.public.signingKey || request.member.agreementKey !== this.identity.public.agreementKey || request.room !== invite.room || request.owner !== invite.owner || request.inviteId !== invite.inviteId || this.rooms.has(name)) fail("untrusted");
542
+ this.transaction(now => {
543
+ if (now > invite.expiresAt + 600000) fail("untrusted");
544
+ for (const row of this.db.query<{ name: string; invite: Uint8Array }, []>("SELECT name,invite FROM pending_joins LIMIT 65").all()) {
545
+ if (now > decodeInvite(row.invite).expiresAt + 600000) this.db.query("DELETE FROM pending_joins WHERE name=?").run(row.name);
546
+ }
547
+ const old = this.db.query<{ invite: Uint8Array; request: Uint8Array }, [string]>("SELECT invite,request FROM pending_joins WHERE name=?").get(name);
548
+ if (old) {
549
+ if (Buffer.compare(Buffer.from(old.invite), Buffer.from(input.invite)) || Buffer.compare(Buffer.from(old.request), Buffer.from(input.request))) fail("state_changed");
550
+ return;
551
+ }
552
+ if ((this.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM pending_joins").get()?.n ?? 0) >= ROOM_STORE_LIMITS.pendingJoins) fail("limit");
553
+ this.db.query("INSERT INTO pending_joins(name,invite,request,selection) VALUES(?,?,?,?)").run(name, input.invite, input.request, input.selection ?? null);
554
+ });
555
+ }
556
+ cancelPendingJoin(name: string): void {
557
+ named(name);
558
+ this.transaction(() => { this.db.query("DELETE FROM pending_joins WHERE name=?").run(name); });
559
+ }
560
+ pendingJoin(name: string): { invite: Uint8Array; request: Uint8Array; selection?: string } | undefined {
561
+ this.now(); named(name);
562
+ const row = this.db.query<{ invite: Uint8Array; request: Uint8Array; selection: string | null }, [string]>("SELECT invite,request,selection FROM pending_joins WHERE name=?").get(name);
563
+ return row ? { invite: copy(row.invite), request: copy(row.request), ...(row.selection !== null ? { selection: row.selection } : {}) } : undefined;
564
+ }
565
+
566
+ private prepareInstall(name: string, broker: string, wire: Uint8Array, expectedDigest: string | null): { previous?: RoomState; pin: { room: string; owner: string; minEpoch: string; digest?: string } } {
567
+ named(name); if (brokerUrl(broker) !== broker) fail("invalid");
568
+ const previous = this.rooms.get(name), roster = decodeRoster(wire);
569
+ if ((previous?.current.digest ?? null) !== expectedDigest || (previous && (previous.current.room !== roster.room || previous.broker !== broker))) fail("state_changed");
570
+ if (previous) return { previous, pin: { room: previous.current.room, owner: decodeRoster(previous.wire).owner, minEpoch: previous.current.epoch, digest: previous.current.digest } };
571
+ if (roster.owner !== this.identity.public.fingerprint) {
572
+ const pending = this.pendingJoin(name); if (!pending) fail("untrusted");
573
+ const invite = decodeInvite(pending.invite);
574
+ if (invite.room !== roster.room || invite.owner !== roster.owner || invite.broker !== broker) fail("untrusted");
575
+ }
576
+ if (this.byRoom(roster.room)) fail("untrusted");
577
+ return { pin: { room: roster.room, owner: roster.owner, minEpoch: "0" } };
578
+ }
579
+ private commitInstall(name: string, broker: string, wire: Uint8Array, current: VerifiedEpoch, previous: RoomState | undefined, now: number): RoomState {
580
+ const live = this.rooms.get(name);
581
+ if ((live?.current.digest ?? null) !== (previous?.current.digest ?? null) || (live?.left ?? false) !== (previous?.left ?? false)) fail("state_changed");
582
+ if (!previous && this.rooms.size >= ROOM_STORE_LIMITS.rooms) fail("limit");
583
+ const same = previous?.current.digest === current.digest;
584
+ const prior = same ? previous?.previous : previous && BigInt(previous.current.epoch) + 1n === BigInt(current.epoch) ? { epoch: previous.current, expiresAt: now + GRACE_MS } : undefined;
585
+ if (prior && !Number.isSafeInteger(prior.expiresAt)) fail("invalid");
586
+ const priorWire = same ? this.db.query<{ previous_wire: Uint8Array | null }, [string]>("SELECT previous_wire FROM rooms WHERE name=?").get(name)?.previous_wire : previous?.wire;
587
+ const next: RoomState = { name, broker, current, wire: copy(wire), ...(prior ? { previous: prior } : {}), left: false };
588
+ this.db.query(`INSERT INTO rooms(name,room,broker,owner,epoch,digest,wire,previous_wire,previous_digest,previous_epoch,previous_expires,left) VALUES(?,?,?,?,?,?,?,?,?,?,?,0)
589
+ 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
+ .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
+ this.db.query("DELETE FROM pending_joins WHERE name=?").run(name);
592
+ return next;
593
+ }
594
+ async install(input: { name: string; broker: string; wire: Uint8Array; expectedDigest: string | null; current?: () => boolean; reactivate?: boolean }): Promise<RoomState> {
595
+ const wire = copy(input.wire), { previous, pin } = this.prepareInstall(input.name, input.broker, wire, input.expectedDigest);
596
+ if (previous?.left && input.reactivate !== true) fail("state_changed");
597
+ const current = await acceptRoomEpoch({ wire, identity: this.identity, pin });
598
+ const next = this.transaction(now => {
599
+ assertCurrent(input.current);
600
+ if (this.rooms.get(input.name)?.left && input.reactivate !== true) fail("state_changed");
601
+ return this.commitInstall(input.name, input.broker, wire, current, previous, now);
602
+ });
603
+ this.rooms.set(input.name, next); return this.snapshot(next);
604
+ }
605
+ async rotate(name: string, members: RoomMember[], expectedDigest: string, current?: () => boolean): Promise<RoomState> {
606
+ const previous = this.get(name);
607
+ if (!previous || previous.left || previous.current.digest !== expectedDigest || decodeRoster(previous.wire).owner !== this.identity.public.fingerprint) fail("untrusted");
608
+ const candidate = await createRoomEpoch({ owner: this.identity, members: structuredClone(members), previous: previous.current });
609
+ return this.install({ name, broker: previous.broker, wire: candidate.wire, expectedDigest, ...(current ? { current } : {}) });
610
+ }
611
+ leave(name: string): void {
612
+ const previous = this.get(name); if (!previous) fail("untrusted");
613
+ this.transaction(() => this.db.query("UPDATE rooms SET left=1 WHERE name=?").run(name));
614
+ this.rooms.set(name, { ...previous, left: true });
615
+ }
616
+ async saveInvite(name: string, wire: Uint8Array, current?: () => boolean): Promise<void> {
617
+ const state = this.get(name), invite = decodeInvite(wire);
618
+ if (!state || state.left || decodeRoster(state.wire).owner !== this.identity.public.fingerprint || invite.owner !== this.identity.public.fingerprint || invite.room !== state.current.room || invite.broker !== state.broker) fail("untrusted");
619
+ wire = copy(wire);
620
+ const hash = await digest(wire);
621
+ this.transaction(now => {
622
+ assertCurrent(current);
623
+ if (invite.expiresAt <= now || this.rooms.get(name)?.current.digest !== state.current.digest || this.rooms.get(name)?.left) fail("untrusted");
624
+ this.db.query("DELETE FROM invites WHERE expires<=?").run(now);
625
+ if ((this.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM invites").get()?.n ?? 0) >= ROOM_STORE_LIMITS.invites) fail("limit");
626
+ this.db.query("INSERT INTO invites(name,id,wire,digest,expires) VALUES(?,?,?,?,?)").run(name, invite.inviteId, wire, hash, invite.expiresAt);
627
+ });
628
+ }
629
+ async enroll(name: string, request: Uint8Array, current?: () => boolean): Promise<RoomState> {
630
+ const previous = this.get(name), joinRequest = decodeJoin(request);
631
+ const invite = this.db.query<InviteRow, [string, string]>("SELECT * FROM invites WHERE name=? AND id=?").get(name, joinRequest.inviteId);
632
+ if (!previous || previous.left || !invite || decodeRoster(previous.wire).owner !== this.identity.public.fingerprint) fail("untrusted");
633
+ let next: RoomState | undefined;
634
+ await acceptJoin({ issuedInvite: copy(invite.wire), wire: copy(request), owner: this.identity, nowMs: this.now(), enrollment: { consume: async claim => {
635
+ const members = roomMembers(previous.current);
636
+ if (members.some(x => x.fingerprint === claim.member.fingerprint)) {
637
+ assertCurrent(current);
638
+ const stored = this.db.query<{ request_digest: string | null }, [string]>("SELECT request_digest FROM invites WHERE id=?").get(claim.inviteId);
639
+ return stored?.request_digest === claim.requestDigest ? "duplicate" : "refused";
640
+ }
641
+ // A consumed invitation never re-enrolls a subsequently revoked member.
642
+ if (invite.request_digest !== null || claim.room !== previous.current.room || claim.owner !== this.identity.public.fingerprint) return "refused";
643
+ const candidate = await createRoomEpoch({ owner: this.identity, members: [...members, claim.member], previous: previous.current });
644
+ next = this.transaction(now => {
645
+ assertCurrent(current);
646
+ const stored = this.db.query<InviteRow, [string]>("SELECT * FROM invites WHERE id=?").get(claim.inviteId);
647
+ if (!stored || stored.name !== name || stored.digest !== claim.inviteDigest || stored.expires !== claim.expiresAt || now >= stored.expires || stored.request_digest !== null) fail("state_changed");
648
+ const state = this.commitInstall(name, previous.broker, candidate.wire, candidate.epoch, previous, now);
649
+ this.db.query("UPDATE invites SET request_digest=? WHERE id=?").run(claim.requestDigest, claim.inviteId);
650
+ return state;
651
+ });
652
+ this.rooms.set(name, next); return "accepted";
653
+ } } });
654
+ return this.snapshot(next ?? this.rooms.get(name)!);
655
+ }
656
+
657
+ private isCurrent(binding: SequenceBinding): boolean {
658
+ this.now(); const state = this.byRoom(binding.room);
659
+ return !!state && !state.left && binding.sender === this.identity.public.fingerprint && state.current.epoch === binding.epoch && state.current.digest === binding.rosterDigest;
660
+ }
661
+ private reserve(binding: SequenceBinding): string {
662
+ return this.transaction(() => {
663
+ if (!this.isCurrent(binding)) fail("state_changed");
664
+ const row = this.db.query<{ seq: string }, [string]>("SELECT seq FROM counters WHERE room=?").get(binding.room);
665
+ const seq = positiveU64(String(BigInt(row?.seq ?? "0") + 1n));
666
+ this.db.query("INSERT INTO counters(room,seq) VALUES(?,?) ON CONFLICT(room) DO UPDATE SET seq=excluded.seq").run(binding.room, seq);
667
+ return seq;
668
+ });
669
+ }
670
+ private accept(input: ReplayCommit): Awaited<ReturnType<ReplayPort["accept"]>> {
671
+ return this.transaction<Awaited<ReturnType<ReplayPort["accept"]>>>(now => {
672
+ const state = this.byRoom(input.room);
673
+ if (!state || state.left || input.recipient !== this.identity.public.fingerprint || state.current.epoch !== input.expectedCurrentEpoch || state.current.digest !== input.expectedCurrentDigest) return { status: "trust_changed" };
674
+ const selected = input.frameEpoch === state.current.epoch ? state.current : state.previous?.epoch;
675
+ if (!selected || selected.epoch !== input.frameEpoch || selected.digest !== input.selectedRosterDigest) return { status: "trust_changed" };
676
+ if (selected !== state.current && (!state.previous || state.previous.expiresAt !== input.previousExpiresAt || now >= state.previous.expiresAt)) return { status: "trust_changed" };
677
+ const currentMembers = roomMembers(state.current), oldMembers = roomMembers(selected);
678
+ for (const fp of [input.sender, input.recipient]) {
679
+ const current = currentMembers.find(x => x.fingerprint === fp), old = oldMembers.find(x => x.fingerprint === fp);
680
+ if (!current || !old || current.signingKey !== old.signingKey || current.agreementKey !== old.agreementKey) return { status: "trust_changed" };
681
+ }
682
+ positiveU64(input.seq);
683
+ const known = this.db.query<{ digest: string }, [string, string, string]>("SELECT digest FROM inbox WHERE room=? AND sender=? AND seq=?").get(input.room, input.sender, input.seq);
684
+ if (known) return { status: known.digest === input.frameDigest ? "duplicate" : "conflict" };
685
+ // An application alias was committed only after crypto replay accepted these exact bytes.
686
+ // Keep its duplicate proof after bounded inbox pruning, without advancing the sender floor.
687
+ const alias = this.db.query("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=? AND f.sender=? AND f.digest=? AND (a.state NOT IN ('completed','failed_safe','disposed') OR a.settled_at>? OR EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=a.room AND p.sender=a.sender AND p.application=a.application))")
688
+ .get(input.room, input.sender, input.frameDigest, now - ROOM_APPLICATION_RETENTION.settledMs);
689
+ if (alias) return { status: "duplicate" };
690
+ const floor = this.db.query<{ seq: string; digest: string }, [string, string]>("SELECT seq,digest FROM floors WHERE room=? AND sender=?").get(input.room, input.sender);
691
+ if (floor && BigInt(input.seq) <= BigInt(floor.seq)) return { status: input.seq === floor.seq ? floor.digest === input.frameDigest ? "duplicate" : "conflict" : "stale" };
692
+ if (!floor && (this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM floors WHERE room=?").get(input.room)?.n ?? 0) >= ROOM_STORE_LIMITS.floorsPerRoom) fail("limit");
693
+ if (!(input.plaintext instanceof Uint8Array) || input.plaintext.length > 65536) fail("limit");
694
+ const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM inbox WHERE room=? AND plaintext IS NOT NULL AND receipt_reserve=0").get(input.room)?.n ?? 0;
695
+ const size = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(plaintext)),0) AS n FROM inbox WHERE receipt_reserve=0").get()?.n ?? 0;
696
+ let reserved = false;
697
+ if (count >= ROOM_STORE_LIMITS.inboxPerRoom || size + input.plaintext.length > ROOM_STORE_LIMITS.inboxBytes) {
698
+ let message: RoomMessage | undefined;
699
+ try { message = decodeRoomMessage(input.plaintext); } catch { /* Malformed or non-application payloads get no capacity retirement proof. */ }
700
+ if (message?.type === "chat") throw new RoomFreshChatCapacityError();
701
+ const receipt = input.plaintext.length <= ROOM_STORE_LIMITS.receiptReserveBytes && message?.type === "receipt" ? message : undefined;
702
+ if (!receipt) fail("limit");
703
+ const matched = this.db.query("SELECT 1 FROM application_outbox WHERE room=? AND digest=? AND application=? AND peer=? AND recipient=? AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL")
704
+ .get(input.room, receipt.frame, receipt.application, receipt.peer, input.sender);
705
+ if (!matched) throw new RoomUnmatchedReceiptError();
706
+ const pending = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM inbox WHERE room=? AND plaintext IS NOT NULL AND receipt_reserve=1").get(input.room)?.n ?? 0;
707
+ const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(plaintext)),0) AS n FROM inbox WHERE receipt_reserve=1").get()?.n ?? 0;
708
+ if (pending >= ROOM_STORE_LIMITS.receiptReservePerRoom || bytes + input.plaintext.length > ROOM_STORE_LIMITS.rooms * ROOM_STORE_LIMITS.receiptReservePerRoom * ROOM_STORE_LIMITS.receiptReserveBytes) fail("limit");
709
+ reserved = true;
710
+ }
711
+ const gap = String(BigInt(input.seq) - BigInt(floor?.seq ?? "0") - 1n);
712
+ this.db.query("INSERT INTO inbox(room,sender,seq,digest,plaintext,gap,receipt_reserve,received_at) VALUES(?,?,?,?,?,?,?,?)").run(input.room, input.sender, input.seq, input.frameDigest, input.plaintext, gap, reserved ? 1 : 0, now);
713
+ this.db.query("INSERT INTO floors(room,sender,seq,digest) VALUES(?,?,?,?) ON CONFLICT(room,sender) DO UPDATE SET seq=excluded.seq,digest=excluded.digest").run(input.room, input.sender, input.seq, input.frameDigest);
714
+ return { status: "accepted", gap };
715
+ });
716
+ }
717
+ async enqueue(name: string, wire: Uint8Array, current?: () => boolean): Promise<void> {
718
+ await this.putOutbox(name, wire, current);
719
+ }
720
+ async queueApplication(name: string, wire: Uint8Array, input: RoomOutgoingInput, current?: () => boolean): Promise<RoomOutgoing> {
721
+ const selected = { ...input };
722
+ if (!boundedId(selected.deliveryId) || !boundedId(selected.application) || !NAME.test(selected.peer)
723
+ || !/^[a-f0-9]{64}$/.test(selected.recipient) || typeof selected.remotePeer !== "string"
724
+ || !new RegExp(`^${selected.peer}@[a-z][a-z0-9-]{0,31}$`).test(selected.remotePeer)
725
+ || !Number.isSafeInteger(selected.ackTimeoutMs) || selected.ackTimeoutMs < 1 || selected.ackTimeoutMs > 86_400_000
726
+ || !Number.isSafeInteger(selected.ttlMs) || selected.ttlMs < 1 || selected.ttlMs > 604_800_000) fail("invalid");
727
+ const result = await this.putOutbox(name, wire, current, selected);
728
+ if (!result) fail("state_changed");
729
+ return result;
730
+ }
731
+ private async putOutbox(name: string, wire: Uint8Array, current?: () => boolean, application?: RoomOutgoingInput): Promise<RoomOutgoing | undefined> {
732
+ wire = copy(wire);
733
+ const state = this.get(name); if (!state || state.left) fail("untrusted");
734
+ const roster = decodeRoster(state.wire);
735
+ const publicRoster = await verifyPublicRoster({ wire: state.wire, pin: { room: state.current.room, owner: roster.owner, minEpoch: state.current.epoch, digest: state.current.digest } });
736
+ const frame = await verifyPublicFrame({ wire, roster: publicRoster });
737
+ if (frame.sender !== this.identity.public.fingerprint) fail("untrusted");
738
+ return this.transaction(now => {
739
+ assertCurrent(current);
740
+ if (!this.isCurrent({ room: frame.room, sender: frame.sender, epoch: frame.epoch, rosterDigest: frame.rosterDigest })) fail("state_changed");
741
+ if (application) {
742
+ const recipient = roomMembers(state.current).find(member => member.fingerprint === application.recipient);
743
+ if (!recipient || application.recipient === this.identity.public.fingerprint || frame.recipients.length !== 1
744
+ || frame.recipients[0] !== application.recipient || application.remotePeer !== `${application.peer}@${recipient.label}`) fail("untrusted");
745
+ }
746
+ const existing = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(frame.digest);
747
+ const saved = this.db.query<OutgoingRow, [string]>("SELECT * FROM application_outbox WHERE digest=?").get(frame.digest);
748
+ if (existing || saved) {
749
+ if (existing && Buffer.compare(Buffer.from(wire), Buffer.from(existing.wire))) fail("state_changed");
750
+ 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);
755
+ }
756
+ if (application && this.db.query("SELECT 1 FROM application_outbox WHERE room=? AND delivery_id=?").get(frame.room, application.deliveryId)) fail("state_changed");
757
+ const count = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM outbox WHERE room=?").get(frame.room)?.n ?? 0;
758
+ const bytes = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(wire)+length(roster)),0) AS n FROM outbox").get()?.n ?? 0;
759
+ if (count >= ROOM_STORE_LIMITS.outboxPerRoom || bytes + wire.length + state.wire.length > ROOM_STORE_LIMITS.outboxBytes) fail("limit");
760
+ this.db.query("INSERT INTO outbox(room,digest,wire,roster,state) VALUES(?,?,?,?,'dispatching')").run(frame.room, frame.digest, wire, state.wire);
761
+ if (!application) return undefined;
762
+ const expires = now + application.ttlMs, deadline = now + application.ackTimeoutMs;
763
+ if (!Number.isSafeInteger(expires) || !Number.isSafeInteger(deadline)) fail("invalid");
764
+ 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
+ .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)!);
767
+ });
768
+ }
769
+ outbox(name: string): { digest: string; wire: Uint8Array; state: "dispatching" | "accepted" }[] {
770
+ 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) }));
772
+ }
773
+ markStored(name: string, digestValue: string): void {
774
+ 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); });
777
+ }
778
+ applicationOutbox(name: string, activeOnly = false): RoomOutgoing[] {
779
+ const state = this.get(name); if (!state) return [];
780
+ const filter = activeOnly ? " AND state IN ('dispatching','accepted','needs_review') AND disposition IS NULL" : "";
781
+ return this.db.query<OutgoingRow, [string]>(`SELECT * FROM application_outbox WHERE room=?${filter} ORDER BY rowid`).all(state.current.room).map(outgoing);
782
+ }
783
+ /** A local send failure after durable enqueue is uncertainty, never evidence of a safe retry. */
784
+ quarantineOutgoing(name: string, deliveryId: string): RoomOutgoing | undefined {
785
+ const state = this.get(name); if (!state) return undefined;
786
+ if (!boundedId(deliveryId)) fail("invalid");
787
+ return this.transaction(() => {
788
+ const row = this.db.query<OutgoingRow, [string, string]>("SELECT * FROM application_outbox WHERE room=? AND delivery_id=?").get(state.current.room, deliveryId);
789
+ if (!row) return undefined;
790
+ 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);
792
+ return outgoing({ ...row, state: "needs_review" });
793
+ });
794
+ }
795
+ markApplicationStored(name: string, digestValue: string, seq: string): RoomOutgoing | undefined {
796
+ const state = this.applicationRoom(name);
797
+ 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);
799
+ const sealed = this.db.query<{ wire: Uint8Array }, [string]>("SELECT wire FROM outbox WHERE digest=?").get(digestValue);
800
+ if (!row || !sealed || decodeFrame(sealed.wire).seq !== seq) return undefined;
801
+ if (row.state !== "dispatching" && row.state !== "accepted") return outgoing(row);
802
+ 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);
805
+ return outgoing({ ...row, state: "needs_review" });
806
+ }
807
+ if (row.state === "accepted") return outgoing(row);
808
+ this.db.query("UPDATE application_outbox SET state='accepted' WHERE digest=?").run(digestValue);
809
+ this.db.query("UPDATE outbox SET state='accepted' WHERE digest=?").run(digestValue);
810
+ return outgoing({ ...row, state: "accepted" });
811
+ });
812
+ }
813
+ private applicationRoom(name: string, current?: () => boolean): RoomState {
814
+ assertCurrent(current);
815
+ const state = this.get(name), own = this.identity.public;
816
+ const member = state && roomMembers(state.current).find(row => row.fingerprint === own.fingerprint);
817
+ if (!state || state.left || !member || member.signingKey !== own.signingKey || member.agreementKey !== own.agreementKey) fail("untrusted");
818
+ return state;
819
+ }
820
+ applyApplicationReceipt(name: string, receiptFrameDigest: string, current?: () => boolean): RoomOutgoing | undefined {
821
+ const state = this.applicationRoom(name, current);
822
+ if (!validDigest(receiptFrameDigest)) fail("invalid");
823
+ return this.transaction(now => {
824
+ this.applicationRoom(name, current);
825
+ const entry = this.db.query<RoomInboxEntry, [string, string]>("SELECT sender,seq,digest,plaintext,gap FROM inbox WHERE room=? AND digest=? AND plaintext IS NOT NULL").get(state.current.room, receiptFrameDigest);
826
+ if (!entry) return undefined;
827
+ let message: RoomMessage; try { message = decodeRoomMessage(entry.plaintext); } catch { fail("invalid"); }
828
+ 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);
830
+ if (!row || row.application !== message.application || row.peer !== message.peer || row.recipient !== entry.sender
831
+ || !roomMembers(state.current).some(member => member.fingerprint === entry.sender) || !validOutcome(message.outcome, message.reason, row.peer)) return undefined;
832
+ 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;
834
+ 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 });
838
+ }
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);
841
+ this.pruneOutgoingHistory(state.current.room);
842
+ return outgoing({ ...row, state: message.outcome, reason: message.reason, receipt_digest: receiptFrameDigest });
843
+ });
844
+ }
845
+ private pruneOutgoingHistory(room: string): void {
846
+ 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
+ .run(room, room, APPLICATION_HISTORY_CAP);
848
+ }
849
+ /** 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 {
851
+ assertCurrent(current);
852
+ const state = this.get(name); if (!state) return undefined;
853
+ if (!boundedId(deliveryId) || !["completed", "retry", "discard"].includes(action)) fail("invalid");
854
+ return this.transaction(() => {
855
+ assertCurrent(current);
856
+ 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;
858
+ 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);
861
+ this.pruneOutgoingHistory(state.current.room);
862
+ return outgoing({ ...row, disposition: action });
863
+ });
864
+ }
865
+ expireApplications(name: string): RoomOutgoing[] {
866
+ const state = this.applicationRoom(name);
867
+ 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);
870
+ return rows.map(row => outgoing({ ...row, state: "needs_review" }));
871
+ });
872
+ }
873
+ private applicationFor(room: string, digestValue: string): ApplicationRow | undefined {
874
+ 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
+ }
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);
878
+ }
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;
881
+ this.db.query("DELETE FROM person_inbox WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
882
+ 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
+ .run(row.room, row.room, row.sender, row.application);
884
+ this.db.query("DELETE FROM application_frames WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
885
+ this.db.query("DELETE FROM applications WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
886
+ }
887
+ private applicationCapacity(room: string, now: number, freshIdentity: boolean, retained?: Pick<ApplicationRow, "sender" | "application">): void {
888
+ this.prunePersonInbox(now);
889
+ // Person stubs have their own disposition/expiry horizon. Only the cap walk
890
+ // 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);
893
+ for (;;) {
894
+ const identities = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM applications WHERE room=?").get(room)?.n ?? 0;
895
+ const aliases = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM application_frames WHERE room=?").get(room)?.n ?? 0;
896
+ if ((!freshIdentity || identities < ROOM_APPLICATION_RETENTION.identitiesPerRoom) && aliases < ROOM_APPLICATION_RETENTION.aliasesPerRoom) return;
897
+ // 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 ?? "");
899
+ if (!settled) throw new RoomApplicationLimitError(room, Math.max(identities, aliases));
900
+ this.deleteSettledApplication(settled);
901
+ }
902
+ }
903
+ async applicationIncoming(name: string, digestValue: string, current?: () => boolean): Promise<{ sender: string; chat: RoomChatEnvelope; contentHash: string; primary: boolean; state: ApplicationState }> {
904
+ const state = this.applicationRoom(name, current);
905
+ if (!validDigest(digestValue)) fail("invalid");
906
+ const original = this.db.query<RoomInboxEntry, [string, string]>("SELECT sender,seq,digest,plaintext,gap FROM inbox WHERE room=? AND digest=? AND plaintext IS NOT NULL").get(state.current.room, digestValue);
907
+ if (!original) fail("untrusted");
908
+ const plaintext = copy(original.plaintext);
909
+ let message: RoomMessage; try { message = decodeRoomMessage(plaintext); } catch { fail("invalid"); }
910
+ if (message.type !== "chat") fail("invalid");
911
+ const chat = message.envelope, contentHash = await digest(encodeRoomMessage(message));
912
+ return this.transaction(now => {
913
+ const latest = this.applicationRoom(name, current);
914
+ if (latest.current.digest !== state.current.digest || !roomMembers(latest.current).some(member => member.fingerprint === original.sender)) fail("state_changed");
915
+ const entry = this.db.query<RoomInboxEntry, [string, string]>("SELECT sender,seq,digest,plaintext,gap FROM inbox WHERE room=? AND digest=? AND plaintext IS NOT NULL").get(state.current.room, digestValue);
916
+ if (!entry || entry.sender !== original.sender || Buffer.compare(Buffer.from(entry.plaintext), Buffer.from(plaintext))) fail("state_changed");
917
+ const key = [state.current.room, original.sender, chat.id] as [string, string, string];
918
+ let saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key);
919
+ if (saved && saved.settled_at !== null && saved.settled_at <= now - ROOM_APPLICATION_RETENTION.settledMs && isSettledApplication(saved.state)) {
920
+ 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; }
922
+ }
923
+ if (saved && (saved.content_hash !== contentHash || saved.peer !== chat.to)) fail("invalid");
924
+ const alias = this.applicationFor(state.current.room, digestValue);
925
+ if (alias && (alias.sender !== original.sender || alias.application !== chat.id)) fail("invalid");
926
+ if (!alias) {
927
+ this.applicationCapacity(state.current.room, now, !saved, saved ?? undefined);
928
+ // The matching retained identity is protected while capacity evicts other settled work.
929
+ saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key);
930
+ if (!saved) {
931
+ const pending = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM applications WHERE room=? AND state='pending'").get(state.current.room)?.n ?? 0;
932
+ if (pending >= ROOM_STORE_LIMITS.inboxPerRoom) fail("limit");
933
+ this.db.query("INSERT INTO applications(room,sender,application,content_hash,primary_digest,peer,trace,hop,state,created_at) VALUES(?,?,?,?,?,?,?,?,'pending',?)")
934
+ .run(...key, contentHash, digestValue, chat.to, chat.trace, chat.hop, now);
935
+ saved = this.db.query<ApplicationRow, [string, string, string]>("SELECT * FROM applications WHERE room=? AND sender=? AND application=?").get(...key)!;
936
+ }
937
+ this.db.query("INSERT INTO application_frames(room,digest,sender,application) VALUES(?,?,?,?)").run(state.current.room, digestValue, original.sender, chat.id);
938
+ }
939
+ if (!saved) fail("corrupt");
940
+ const primary = saved.primary_digest === digestValue;
941
+ if (!primary || isSettledApplication(saved.state) || saved.state === "needs_review") {
942
+ this.db.query("UPDATE inbox SET plaintext=NULL WHERE room=? AND digest=?").run(state.current.room, digestValue);
943
+ this.pruneInbox(state.current.room);
944
+ }
945
+ return { sender: original.sender, chat, contentHash, primary, state: saved.state };
946
+ });
947
+ }
948
+ private personMeta(row: PersonInboxRow): RoomPersonInboxMeta {
949
+ const state = this.byRoom(row.room); if (!state) fail("corrupt");
950
+ return { id: row.id, name: state.name, room: row.room, sender: row.sender, from: row.from_peer,
951
+ receivedAt: row.received_at, expiresAt: row.expires_at, bytes: row.bytes, state: row.state };
952
+ }
953
+ private prunePersonInbox(now: number): void {
954
+ const expired = this.db.query("UPDATE person_inbox SET body=NULL,state='expired',settled_at=expires_at WHERE state='unread' AND expires_at<=?").run(now).changes;
955
+ const removed = this.db.query("DELETE FROM person_inbox WHERE state!='unread' AND settled_at<=?").run(now - ROOM_PERSON_INBOX_LIMITS.ttlMs).changes;
956
+ if (expired > 0 || removed > 0) this.pendingPersonPurge = true;
957
+ for (const state of this.rooms.values()) {
958
+ const capped = this.db.query("DELETE FROM person_inbox WHERE room=? AND state!='unread' AND id NOT IN (SELECT id FROM person_inbox WHERE room=? AND state!='unread' ORDER BY settled_at DESC,id DESC LIMIT ?)")
959
+ .run(state.current.room, state.current.room, ROOM_PERSON_INBOX_LIMITS.stubsPerRoom).changes;
960
+ if (capped > 0) this.pendingPersonPurge = true;
961
+ }
962
+ }
963
+ expirePersonInbox(): boolean {
964
+ const now = this.now();
965
+ // Idle ticks inspect indexed metadata only, with no transaction or durable clock write.
966
+ const due = this.db.query("SELECT 1 FROM person_inbox WHERE (state='unread' AND expires_at<=?) OR (state!='unread' AND settled_at<=?) LIMIT 1").get(now, now - ROOM_PERSON_INBOX_LIMITS.ttlMs);
967
+ if (due || this.db.query("SELECT room FROM person_inbox WHERE state!='unread' GROUP BY room HAVING COUNT(*)>1000 LIMIT 1").get()) this.transaction(current => this.prunePersonInbox(current));
968
+ // Indirect cleanup (show/admission/capacity) reports its committed wake once,
969
+ // even when this tick has nothing left to prune. No body or clock write is needed.
970
+ const purged = this.pendingPersonPurge; this.pendingPersonPurge = false;
971
+ return purged;
972
+ }
973
+ /** A person inbox and its immutable admission receipt commit together. No Bus or network effect occurs here. */
974
+ admitPersonInbox(name: string, digestValue: string, current?: () => boolean): RoomPersonInboxAdmission {
975
+ const state = this.applicationRoom(name, current);
976
+ if (!validDigest(digestValue)) fail("invalid");
977
+ return this.transaction(now => {
978
+ const latest = this.applicationRoom(name, current);
979
+ if (latest.current.digest !== state.current.digest) fail("state_changed");
980
+ this.prunePersonInbox(now);
981
+ const row = this.applicationFor(state.current.room, digestValue);
982
+ if (!row || row.peer !== USER) fail("untrusted");
983
+ if (isTerminal(row.state)) {
984
+ if (!validOutcome(row.state, row.reason, row.peer)) fail("corrupt");
985
+ const existing = this.db.query<{ id: string }, [string, string, string]>("SELECT id FROM person_inbox WHERE room=? AND sender=? AND application=?").get(row.room, row.sender, row.application);
986
+ return { state: row.reason === "inbox_admitted" ? "duplicate" : "refused", reason: row.reason!, ...(existing ? { id: existing.id } : {}) };
987
+ }
988
+ if (row.state !== "pending" || row.primary_digest !== digestValue) fail("state_changed");
989
+ const entry = this.db.query<{ plaintext: Uint8Array; received_at: number | null }, [string, string, string]>("SELECT plaintext,received_at FROM inbox WHERE room=? AND digest=? AND sender=? AND plaintext IS NOT NULL").get(row.room, digestValue, row.sender);
990
+ if (!entry) fail("untrusted");
991
+ const message = decodeRoomMessage(entry.plaintext);
992
+ if (message.type !== "chat" || message.envelope.to !== USER || message.envelope.id !== row.application || message.envelope.trace !== row.trace || message.envelope.hop !== row.hop
993
+ || new Bun.CryptoHasher("sha256").update(encodeRoomMessage(message)).digest("base64url") !== row.content_hash) fail("untrusted");
994
+ const member = roomMembers(latest.current).find(value => value.fingerprint === row.sender);
995
+ if (!member || (message.envelope.from !== USER && !member.exposedPeers.includes(message.envelope.from))) fail("untrusted");
996
+ const bytes = Buffer.byteLength(message.envelope.body, "utf8"), received = entry.received_at;
997
+ let reason: RoomReceiptReason | undefined;
998
+ if (received === null) reason = "inbox_age_unavailable";
999
+ else if (!Number.isSafeInteger(received) || received <= 0 || received > now || !Number.isSafeInteger(received + ROOM_PERSON_INBOX_LIMITS.ttlMs)) fail("corrupt");
1000
+ else if (received + ROOM_PERSON_INBOX_LIMITS.ttlMs <= now) reason = "inbox_expired";
1001
+ else if (bytes > ROOM_PERSON_INBOX_LIMITS.bodyBytes) reason = "inbox_oversize";
1002
+ else {
1003
+ const unread = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM person_inbox WHERE room=? AND state='unread'").get(row.room)?.n ?? 0;
1004
+ const retained = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(body)),0) AS n FROM person_inbox WHERE body IS NOT NULL").get()?.n ?? 0;
1005
+ if (unread >= ROOM_PERSON_INBOX_LIMITS.unreadPerRoom || retained + entry.plaintext.byteLength > ROOM_PERSON_INBOX_LIMITS.globalBytes) reason = "inbox_full";
1006
+ }
1007
+ assertCurrent(current);
1008
+ if (reason) {
1009
+ this.finishApplication(name, digestValue, "failed_safe", reason, current);
1010
+ return { state: "refused", reason };
1011
+ }
1012
+ const id = crypto.randomUUID();
1013
+ this.db.query("INSERT INTO person_inbox(id,room,sender,application,primary_digest,from_peer,received_at,expires_at,bytes,body,state) VALUES(?,?,?,?,?,?,?,?,?,?,'unread')")
1014
+ .run(id, row.room, row.sender, row.application, digestValue, `${message.envelope.from}@${member.label}`, received!, received! + ROOM_PERSON_INBOX_LIMITS.ttlMs, bytes, entry.plaintext);
1015
+ this.db.query("UPDATE applications SET state='completed',reason='inbox_admitted',settled_at=? WHERE room=? AND sender=? AND application=?").run(now, row.room, row.sender, row.application);
1016
+ this.db.query("UPDATE inbox SET plaintext=NULL WHERE room=? AND digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)").run(row.room, row.room, row.sender, row.application);
1017
+ this.pruneInbox(row.room);
1018
+ return { state: "admitted", reason: "inbox_admitted", id };
1019
+ });
1020
+ }
1021
+ listPersonInbox(name?: string): RoomPersonInboxMeta[] {
1022
+ this.assertOpen(); const now = this.now();
1023
+ const state = name === undefined ? undefined : this.get(named(name));
1024
+ if (name !== undefined && !state) return [];
1025
+ const rows = state ? this.db.query<PersonInboxRow, [string]>("SELECT id,room,sender,application,primary_digest,from_peer,received_at,expires_at,bytes,state,settled_at FROM person_inbox WHERE room=? ORDER BY received_at,id LIMIT 1101").all(state.current.room)
1026
+ : this.db.query<PersonInboxRow, []>("SELECT id,room,sender,application,primary_digest,from_peer,received_at,expires_at,bytes,state,settled_at FROM person_inbox ORDER BY received_at,id LIMIT 1101").all();
1027
+ // ponytail: one complete bounded room (1100 rows), no global truncation. Add versioned pagination if global inventory is required.
1028
+ if (rows.length > 1100) fail("inbox_list_limit");
1029
+ // Metadata projection never loads a body; callers cannot infer a fresh unread TTL from a stale row.
1030
+ return rows.map(row => this.personMeta(row.state === "unread" && row.expires_at <= now ? { ...row, state: "expired" } : row));
1031
+ }
1032
+ readPersonInbox(id: string, current: () => boolean): RoomPersonInboxItem | undefined {
1033
+ if (typeof current !== "function") fail("untrusted"); assertCurrent(current);
1034
+ if (!validUuid(id)) fail("invalid");
1035
+ return this.transaction(now => {
1036
+ assertCurrent(current); this.prunePersonInbox(now);
1037
+ const row = this.db.query<PersonInboxRow, [string]>("SELECT * FROM person_inbox WHERE id=?").get(id);
1038
+ if (!row) return undefined;
1039
+ const item: RoomPersonInboxItem = this.personMeta(row);
1040
+ if (row.body !== null) {
1041
+ const message = decodeRoomMessage(row.body); if (message.type !== "chat") fail("corrupt"); item.body = message.envelope.body;
1042
+ }
1043
+ assertCurrent(current); return item;
1044
+ });
1045
+ }
1046
+ disposePersonInbox(id: string, action: "done" | "discard", current: () => boolean): RoomPersonInboxMeta | undefined {
1047
+ if (typeof current !== "function") fail("untrusted"); assertCurrent(current);
1048
+ if (!validUuid(id) || action !== "done" && action !== "discard") fail("invalid");
1049
+ return this.transaction(now => {
1050
+ assertCurrent(current); this.prunePersonInbox(now);
1051
+ const row = this.db.query<PersonInboxRow, [string]>("SELECT id,room,sender,application,primary_digest,from_peer,received_at,expires_at,bytes,state,settled_at FROM person_inbox WHERE id=?").get(id);
1052
+ if (!row) return undefined;
1053
+ const next = action === "done" ? "done" : "discarded";
1054
+ if (row.state !== "unread") { if (row.state !== next && row.state !== "expired") fail("state_changed"); return this.personMeta(row); }
1055
+ assertCurrent(current);
1056
+ this.db.query("UPDATE person_inbox SET body=NULL,state=?,settled_at=? WHERE id=?").run(next, now, id);
1057
+ this.prunePersonInbox(now);
1058
+ return this.personMeta({ ...row, body: null, state: next, settled_at: now });
1059
+ });
1060
+ }
1061
+ claimApplication(name: string, digestValue: string, current?: () => boolean): boolean {
1062
+ const state = this.applicationRoom(name, current);
1063
+ return this.transaction(() => {
1064
+ this.applicationRoom(name, current);
1065
+ const row = this.applicationFor(state.current.room, digestValue);
1066
+ if (!row || row.primary_digest !== digestValue || row.state !== "pending") return false;
1067
+ if (!roomMembers(state.current).some(member => member.fingerprint === row.sender)) fail("untrusted");
1068
+ this.db.query("UPDATE applications SET state='dispatching' WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
1069
+ return true;
1070
+ });
1071
+ }
1072
+ finishApplication(name: string, digestValue: string, outcome: "completed" | "failed_safe", reason: RoomReceiptReason, current?: () => boolean): void {
1073
+ const state = this.applicationRoom(name, current);
1074
+ if (!validOutcome(outcome, reason)) fail("invalid");
1075
+ this.transaction(now => {
1076
+ this.applicationRoom(name, current);
1077
+ const row = this.applicationFor(state.current.room, digestValue);
1078
+ if (!row || row.primary_digest !== digestValue || row.state === "needs_review" || row.state === "disposed") fail("state_changed");
1079
+ if (!validOutcome(outcome, reason, row.peer) || reason === "inbox_admitted") fail("invalid");
1080
+ if (isTerminal(row.state)) { if (row.state !== outcome || row.reason !== reason) fail("state_changed"); return; }
1081
+ if (outcome === "completed" && row.state !== "dispatching") fail("state_changed");
1082
+ this.db.query("UPDATE applications SET state=?,reason=?,settled_at=? WHERE room=? AND sender=? AND application=?").run(outcome, reason, now, row.room, row.sender, row.application);
1083
+ this.db.query("UPDATE inbox SET plaintext=NULL WHERE room=? AND digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
1084
+ .run(row.room, row.room, row.sender, row.application);
1085
+ this.pruneInbox(row.room);
1086
+ });
1087
+ }
1088
+ applicationReceipt(name: string, digestValue: string): RoomMessage | undefined {
1089
+ const state = this.applicationRoom(name), row = this.applicationFor(state.current.room, digestValue);
1090
+ if (!row || !isTerminal(row.state) || !validOutcome(row.state, row.reason, row.peer) || row.settled_at === null || row.settled_at <= this.now() - ROOM_APPLICATION_RETENTION.settledMs && !this.personIdentityRetained(row)) return undefined;
1091
+ return { v: 1, type: "receipt", frame: digestValue, application: row.application, peer: row.peer, outcome: row.state as "completed" | "failed_safe", reason: row.reason! };
1092
+ }
1093
+ pendingApplications(name: string): { digest: string }[] {
1094
+ const state = this.applicationRoom(name);
1095
+ return this.db.query<{ digest: string }, [string]>("SELECT primary_digest AS digest FROM applications WHERE room=? AND state='pending' ORDER BY created_at,primary_digest").all(state.current.room);
1096
+ }
1097
+ quarantineApplication(name: string, digestValue: string, current?: () => boolean): void {
1098
+ const state = this.applicationRoom(name, current);
1099
+ this.transaction(() => {
1100
+ this.applicationRoom(name, current);
1101
+ const row = this.applicationFor(state.current.room, digestValue);
1102
+ if (!row || isSettledApplication(row.state)) fail("state_changed");
1103
+ this.db.query("UPDATE applications SET state='needs_review' WHERE room=? AND sender=? AND application=?").run(row.room, row.sender, row.application);
1104
+ this.db.query("UPDATE inbox SET plaintext=NULL WHERE room=? AND digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
1105
+ .run(row.room, row.room, row.sender, row.application);
1106
+ this.pruneInbox(row.room);
1107
+ });
1108
+ }
1109
+ incomingApplication(name: string, digestValue: string): RoomIncomingApplication | undefined {
1110
+ const state = this.get(name); if (!state) return undefined;
1111
+ if (!validDigest(digestValue)) fail("invalid");
1112
+ const row = this.applicationFor(state.current.room, digestValue);
1113
+ if (!row || isSettledApplication(row.state) && row.settled_at! <= this.now() - ROOM_APPLICATION_RETENTION.settledMs && !this.personIdentityRetained(row)) return undefined;
1114
+ return incomingMetadata(row, digestValue);
1115
+ }
1116
+ /** A person disposes uncertainty locally; accept is not Bus admission or signed remote completion. */
1117
+ disposeIncoming(name: string, digestValue: string, action: "accept" | "discard", current?: () => boolean): void {
1118
+ assertCurrent(current);
1119
+ const state = this.get(name); if (!state) fail("untrusted");
1120
+ if (!validDigest(digestValue) || !["accept", "discard"].includes(action)) fail("invalid");
1121
+ this.transaction(now => {
1122
+ assertCurrent(current);
1123
+ const row = this.applicationFor(state.current.room, digestValue);
1124
+ if (!row || row.state !== "needs_review") fail("state_changed");
1125
+ this.db.query("UPDATE applications SET state='disposed',disposition=?,disposed_at=?,disposed_by='user',settled_at=? WHERE room=? AND sender=? AND application=?")
1126
+ .run(action, now, now, row.room, row.sender, row.application);
1127
+ this.db.query("UPDATE inbox SET plaintext=NULL WHERE room=? AND digest IN (SELECT digest FROM application_frames WHERE room=? AND sender=? AND application=?)")
1128
+ .run(row.room, row.room, row.sender, row.application);
1129
+ this.pruneInbox(row.room);
1130
+ });
1131
+ }
1132
+ applicationStatus(name: string): { pending: number; needsReview: number; rows: RoomIncomingApplication[]; truncated: boolean } {
1133
+ const state = this.get(name);
1134
+ if (!state) return { pending: 0, needsReview: 0, rows: [], truncated: false };
1135
+ const cutoff = this.now() - ROOM_APPLICATION_RETENTION.settledMs;
1136
+ const counts = this.db.query<{ pending: number; needsReview: number; disposed: number }, [number, string]>("SELECT COALESCE(SUM(CASE WHEN state IN ('pending','dispatching') THEN 1 ELSE 0 END),0) AS pending,COALESCE(SUM(CASE WHEN state='needs_review' THEN 1 ELSE 0 END),0) AS needsReview,COALESCE(SUM(CASE WHEN state='disposed' AND disposed_at>? THEN 1 ELSE 0 END),0) AS disposed FROM applications WHERE room=?").get(cutoff, state.current.room)!;
1137
+ const rows: RoomIncomingApplication[] = [];
1138
+ for (const selected of ["needs_review", "dispatching", "pending"]) {
1139
+ if (rows.length === 200) break;
1140
+ rows.push(...this.db.query<RoomIncomingApplication, [string, string, number]>("SELECT application,sender,peer,state,primary_digest AS digest FROM applications WHERE room=? AND state=? ORDER BY created_at,primary_digest LIMIT ?").all(state.current.room, selected, 200 - rows.length));
1141
+ }
1142
+ if (rows.length < 200) rows.push(...this.db.query<ApplicationRow, [string, number, number]>("SELECT * FROM applications WHERE room=? AND state='disposed' AND disposed_at>? ORDER BY disposed_at DESC,primary_digest LIMIT ?").all(state.current.room, cutoff, 200 - rows.length).map(row => incomingMetadata(row)));
1143
+ return { pending: counts.pending, needsReview: counts.needsReview, rows, truncated: counts.pending + counts.needsReview + counts.disposed > rows.length };
1144
+ }
1145
+ /** Same bounded application tombstones, never a separate unbounded trace history. */
1146
+ applicationTraceHop(trace: string): number | undefined {
1147
+ if (!validUuid(trace)) fail("invalid");
1148
+ const now = this.now();
1149
+ const row = this.db.query<{ hop: number | null }, [string, number]>("SELECT MAX(hop) AS hop FROM applications WHERE trace=? AND (state NOT IN ('completed','failed_safe','disposed') OR settled_at>? OR EXISTS (SELECT 1 FROM person_inbox p WHERE p.room=applications.room AND p.sender=applications.sender AND p.application=applications.application))").get(trace, now - ROOM_APPLICATION_RETENTION.settledMs);
1150
+ if (row?.hop === null || row === null) return undefined;
1151
+ if (!Number.isSafeInteger(row.hop) || row.hop! < 0 || row.hop! > MAX_HOP) fail("corrupt");
1152
+ return row.hop!;
1153
+ }
1154
+ private recoverApplications(): void {
1155
+ this.transaction(() => {
1156
+ this.db.query("UPDATE application_outbox SET state='needs_review' WHERE state IN ('dispatching','accepted')").run();
1157
+ this.db.query("UPDATE applications SET state='needs_review' WHERE state='dispatching'").run();
1158
+ 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
+ for (const state of this.rooms.values()) this.pruneInbox(state.current.room);
1160
+ });
1161
+ }
1162
+ /** Diagnostic transport-drop receipt only. It never advances crypto replay or application-delivery state. */
1163
+ recordRefused(name: string, digestValue: string, reason: RoomRefusalReason, current?: () => boolean): void {
1164
+ const state = this.get(name);
1165
+ if (!state || state.left) fail("untrusted");
1166
+ if (!validDigest(digestValue) || !ROOM_REFUSAL_REASONS.includes(reason)) fail("invalid");
1167
+ this.transaction(now => {
1168
+ assertCurrent(current);
1169
+ const latest = this.rooms.get(name);
1170
+ if (!latest || latest.left || latest.current.digest !== state.current.digest) fail("state_changed");
1171
+ this.db.query("INSERT INTO refusals(room,digest,reason,at) VALUES(?,?,?,?) ON CONFLICT(room,digest) DO NOTHING")
1172
+ .run(state.current.room, digestValue, reason, now);
1173
+ // These are non-authority diagnostics; evicting them never forgets a replay floor or accepted receipt.
1174
+ this.db.query("DELETE FROM refusals WHERE room=? AND rowid NOT IN (SELECT rowid FROM refusals WHERE room=? ORDER BY rowid DESC LIMIT ?)")
1175
+ .run(state.current.room, state.current.room, ROOM_STORE_LIMITS.refusedPerRoom);
1176
+ });
1177
+ }
1178
+ refused(name: string): RoomRefusal[] {
1179
+ const state = this.get(name); if (!state) return [];
1180
+ return this.db.query<RoomRefusal, [string]>("SELECT digest,reason,at FROM refusals WHERE room=? ORDER BY rowid").all(state.current.room);
1181
+ }
1182
+ /** Read-only physical-cap hint for a validated host payload; no body is loaded and no replay/clock row is written. */
1183
+ ordinaryInboxAvailable(name: string, bytes: number): boolean {
1184
+ this.now(); named(name);
1185
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > 65536) fail("invalid");
1186
+ const state = this.rooms.get(name); if (!state || state.left) fail("untrusted");
1187
+ try {
1188
+ const pending = this.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM inbox WHERE room=? AND plaintext IS NOT NULL AND receipt_reserve=0").get(state.current.room);
1189
+ const total = this.db.query<{ n: number }, []>("SELECT COALESCE(SUM(length(plaintext)),0) AS n FROM inbox WHERE receipt_reserve=0").get();
1190
+ if (!pending || !total || !Number.isSafeInteger(pending.n) || pending.n < 0 || !Number.isSafeInteger(total.n) || total.n < 0) fail("corrupt");
1191
+ return pending.n < ROOM_STORE_LIMITS.inboxPerRoom && total.n + bytes <= ROOM_STORE_LIMITS.inboxBytes;
1192
+ } catch (error) { if (error instanceof RoomStoreError) throw error; throw new RoomStoreError(code(error)); }
1193
+ }
1194
+ pendingInbox(name: string): RoomInboxEntry[] {
1195
+ const room = this.get(name); if (!room) return [];
1196
+ return this.db.query<{ sender: string; seq: string; digest: string; plaintext: Uint8Array; gap: string }, [string]>("SELECT sender,seq,digest,plaintext,gap FROM inbox WHERE room=? AND plaintext IS NOT NULL ORDER BY rowid").all(room.current.room).map(x => ({ ...x, plaintext: copy(x.plaintext) }));
1197
+ }
1198
+ private pruneInbox(room: string): void {
1199
+ this.db.query("DELETE FROM inbox WHERE room=? AND plaintext IS NULL AND rowid NOT IN (SELECT rowid FROM inbox WHERE room=? ORDER BY rowid DESC LIMIT ?)")
1200
+ .run(room, room, ROOM_STORE_LIMITS.receiptsPerRoom);
1201
+ }
1202
+ settleInbox(name: string, digestValue: string): void {
1203
+ const room = this.get(name); if (!room) fail("untrusted");
1204
+ this.transaction(() => {
1205
+ this.db.query("UPDATE inbox SET plaintext=NULL WHERE room=? AND digest=?").run(room.current.room, digestValue);
1206
+ this.pruneInbox(room.current.room);
1207
+ });
1208
+ }
1209
+ close(): void { if (!this.closed) { this.closed = true; this.db.close(); this.rooms.clear(); } }
1210
+ }