@smthrs/sync 0.0.0-stage → 1.0.0-rc.4

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 (266) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -2
  4. package/dist/cjs/BranchCommands.d.ts +228 -0
  5. package/dist/cjs/BranchCommands.d.ts.map +1 -0
  6. package/dist/cjs/BranchCommands.js +316 -0
  7. package/dist/cjs/BranchCommands.js.map +7 -0
  8. package/dist/cjs/BranchIds.d.ts +65 -0
  9. package/dist/cjs/BranchIds.d.ts.map +1 -0
  10. package/dist/cjs/BranchIds.js +53 -0
  11. package/dist/cjs/BranchIds.js.map +7 -0
  12. package/dist/cjs/BranchPresence.d.ts +195 -0
  13. package/dist/cjs/BranchPresence.d.ts.map +1 -0
  14. package/dist/cjs/BranchPresence.js +276 -0
  15. package/dist/cjs/BranchPresence.js.map +7 -0
  16. package/dist/cjs/BranchProjection.d.ts +179 -0
  17. package/dist/cjs/BranchProjection.d.ts.map +1 -0
  18. package/dist/cjs/BranchProjection.js +197 -0
  19. package/dist/cjs/BranchProjection.js.map +7 -0
  20. package/dist/cjs/BranchProtocol.d.ts +415 -0
  21. package/dist/cjs/BranchProtocol.d.ts.map +1 -0
  22. package/dist/cjs/BranchProtocol.js +172 -0
  23. package/dist/cjs/BranchProtocol.js.map +7 -0
  24. package/dist/cjs/BranchRpcs.d.ts +177 -0
  25. package/dist/cjs/BranchRpcs.d.ts.map +1 -0
  26. package/dist/cjs/BranchRpcs.js +85 -0
  27. package/dist/cjs/BranchRpcs.js.map +7 -0
  28. package/dist/cjs/BranchServer.d.ts +55 -0
  29. package/dist/cjs/BranchServer.d.ts.map +1 -0
  30. package/dist/cjs/BranchServer.js +147 -0
  31. package/dist/cjs/BranchServer.js.map +7 -0
  32. package/dist/cjs/BranchShare.d.ts +159 -0
  33. package/dist/cjs/BranchShare.d.ts.map +1 -0
  34. package/dist/cjs/BranchShare.js +147 -0
  35. package/dist/cjs/BranchShare.js.map +7 -0
  36. package/dist/cjs/RunCatalog.d.ts +207 -0
  37. package/dist/cjs/RunCatalog.d.ts.map +1 -0
  38. package/dist/cjs/RunCatalog.js +122 -0
  39. package/dist/cjs/RunCatalog.js.map +7 -0
  40. package/dist/cjs/SyncAuth.d.ts +70 -0
  41. package/dist/cjs/SyncAuth.d.ts.map +1 -0
  42. package/dist/cjs/SyncAuth.js +109 -0
  43. package/dist/cjs/SyncAuth.js.map +7 -0
  44. package/dist/cjs/SyncClient.d.ts +274 -0
  45. package/dist/cjs/SyncClient.d.ts.map +1 -0
  46. package/dist/cjs/SyncClient.js +474 -0
  47. package/dist/cjs/SyncClient.js.map +7 -0
  48. package/dist/cjs/SyncError.d.ts +107 -0
  49. package/dist/cjs/SyncError.d.ts.map +1 -0
  50. package/dist/cjs/SyncError.js +110 -0
  51. package/dist/cjs/SyncError.js.map +7 -0
  52. package/dist/cjs/SyncPrincipal.d.ts +100 -0
  53. package/dist/cjs/SyncPrincipal.d.ts.map +1 -0
  54. package/dist/cjs/SyncPrincipal.js +48 -0
  55. package/dist/cjs/SyncPrincipal.js.map +7 -0
  56. package/dist/cjs/SyncProtocol.d.ts +592 -0
  57. package/dist/cjs/SyncProtocol.d.ts.map +1 -0
  58. package/dist/cjs/SyncProtocol.js +165 -0
  59. package/dist/cjs/SyncProtocol.js.map +7 -0
  60. package/dist/cjs/SyncRpcs.d.ts +92 -0
  61. package/dist/cjs/SyncRpcs.d.ts.map +1 -0
  62. package/dist/cjs/SyncRpcs.js +35 -0
  63. package/dist/cjs/SyncRpcs.js.map +7 -0
  64. package/dist/cjs/SyncServer.d.ts +184 -0
  65. package/dist/cjs/SyncServer.d.ts.map +1 -0
  66. package/dist/cjs/SyncServer.js +513 -0
  67. package/dist/cjs/SyncServer.js.map +7 -0
  68. package/dist/cjs/WorkspaceShare.d.ts +174 -0
  69. package/dist/cjs/WorkspaceShare.d.ts.map +1 -0
  70. package/dist/cjs/WorkspaceShare.js +149 -0
  71. package/dist/cjs/WorkspaceShare.js.map +7 -0
  72. package/dist/cjs/index.d.ts +91 -0
  73. package/dist/cjs/index.d.ts.map +1 -0
  74. package/dist/cjs/index.js +67 -0
  75. package/dist/cjs/index.js.map +7 -0
  76. package/dist/cjs/internal/Admission.d.ts +43 -0
  77. package/dist/cjs/internal/Admission.d.ts.map +1 -0
  78. package/dist/cjs/internal/Admission.js +90 -0
  79. package/dist/cjs/internal/Admission.js.map +7 -0
  80. package/dist/cjs/internal/CauseText.d.ts +55 -0
  81. package/dist/cjs/internal/CauseText.d.ts.map +1 -0
  82. package/dist/cjs/internal/CauseText.js +38 -0
  83. package/dist/cjs/internal/CauseText.js.map +7 -0
  84. package/dist/cjs/internal/JournalErrorCode.d.ts +23 -0
  85. package/dist/cjs/internal/JournalErrorCode.d.ts.map +1 -0
  86. package/dist/cjs/internal/JournalErrorCode.js +34 -0
  87. package/dist/cjs/internal/JournalErrorCode.js.map +7 -0
  88. package/dist/cjs/internal/PolicyOptions.d.ts +57 -0
  89. package/dist/cjs/internal/PolicyOptions.d.ts.map +1 -0
  90. package/dist/cjs/internal/PolicyOptions.js +62 -0
  91. package/dist/cjs/internal/PolicyOptions.js.map +7 -0
  92. package/dist/cjs/internal/ProtocolVersion.d.ts +17 -0
  93. package/dist/cjs/internal/ProtocolVersion.d.ts.map +1 -0
  94. package/dist/cjs/internal/ProtocolVersion.js +44 -0
  95. package/dist/cjs/internal/ProtocolVersion.js.map +7 -0
  96. package/dist/cjs/internal/ShareSigner.d.ts +173 -0
  97. package/dist/cjs/internal/ShareSigner.d.ts.map +1 -0
  98. package/dist/cjs/internal/ShareSigner.js +150 -0
  99. package/dist/cjs/internal/ShareSigner.js.map +7 -0
  100. package/dist/cjs/internal/SnapshotBoundary.d.ts +17 -0
  101. package/dist/cjs/internal/SnapshotBoundary.d.ts.map +1 -0
  102. package/dist/cjs/internal/SnapshotBoundary.js +97 -0
  103. package/dist/cjs/internal/SnapshotBoundary.js.map +7 -0
  104. package/dist/cjs/package.json +1 -0
  105. package/dist/cjs/test/TestSocket.d.ts +55 -0
  106. package/dist/cjs/test/TestSocket.d.ts.map +1 -0
  107. package/dist/cjs/test/TestSocket.js +145 -0
  108. package/dist/cjs/test/TestSocket.js.map +7 -0
  109. package/dist/cjs/test/TestSync.d.ts +54 -0
  110. package/dist/cjs/test/TestSync.d.ts.map +1 -0
  111. package/dist/cjs/test/TestSync.js +96 -0
  112. package/dist/cjs/test/TestSync.js.map +7 -0
  113. package/dist/esm/BranchCommands.d.ts +228 -0
  114. package/dist/esm/BranchCommands.d.ts.map +1 -0
  115. package/dist/esm/BranchCommands.js +496 -0
  116. package/dist/esm/BranchCommands.js.map +1 -0
  117. package/dist/esm/BranchIds.d.ts +65 -0
  118. package/dist/esm/BranchIds.d.ts.map +1 -0
  119. package/dist/esm/BranchIds.js +61 -0
  120. package/dist/esm/BranchIds.js.map +1 -0
  121. package/dist/esm/BranchPresence.d.ts +195 -0
  122. package/dist/esm/BranchPresence.d.ts.map +1 -0
  123. package/dist/esm/BranchPresence.js +361 -0
  124. package/dist/esm/BranchPresence.js.map +1 -0
  125. package/dist/esm/BranchProjection.d.ts +179 -0
  126. package/dist/esm/BranchProjection.d.ts.map +1 -0
  127. package/dist/esm/BranchProjection.js +269 -0
  128. package/dist/esm/BranchProjection.js.map +1 -0
  129. package/dist/esm/BranchProtocol.d.ts +415 -0
  130. package/dist/esm/BranchProtocol.d.ts.map +1 -0
  131. package/dist/esm/BranchProtocol.js +323 -0
  132. package/dist/esm/BranchProtocol.js.map +1 -0
  133. package/dist/esm/BranchRpcs.d.ts +177 -0
  134. package/dist/esm/BranchRpcs.d.ts.map +1 -0
  135. package/dist/esm/BranchRpcs.js +103 -0
  136. package/dist/esm/BranchRpcs.js.map +1 -0
  137. package/dist/esm/BranchServer.d.ts +55 -0
  138. package/dist/esm/BranchServer.d.ts.map +1 -0
  139. package/dist/esm/BranchServer.js +168 -0
  140. package/dist/esm/BranchServer.js.map +1 -0
  141. package/dist/esm/BranchShare.d.ts +159 -0
  142. package/dist/esm/BranchShare.d.ts.map +1 -0
  143. package/dist/esm/BranchShare.js +213 -0
  144. package/dist/esm/BranchShare.js.map +1 -0
  145. package/dist/esm/RunCatalog.d.ts +207 -0
  146. package/dist/esm/RunCatalog.d.ts.map +1 -0
  147. package/dist/esm/RunCatalog.js +196 -0
  148. package/dist/esm/RunCatalog.js.map +1 -0
  149. package/dist/esm/SyncAuth.d.ts +70 -0
  150. package/dist/esm/SyncAuth.d.ts.map +1 -0
  151. package/dist/esm/SyncAuth.js +119 -0
  152. package/dist/esm/SyncAuth.js.map +1 -0
  153. package/dist/esm/SyncClient.d.ts +274 -0
  154. package/dist/esm/SyncClient.d.ts.map +1 -0
  155. package/dist/esm/SyncClient.js +586 -0
  156. package/dist/esm/SyncClient.js.map +1 -0
  157. package/dist/esm/SyncError.d.ts +107 -0
  158. package/dist/esm/SyncError.d.ts.map +1 -0
  159. package/dist/esm/SyncError.js +133 -0
  160. package/dist/esm/SyncError.js.map +1 -0
  161. package/dist/esm/SyncPrincipal.d.ts +100 -0
  162. package/dist/esm/SyncPrincipal.d.ts.map +1 -0
  163. package/dist/esm/SyncPrincipal.js +58 -0
  164. package/dist/esm/SyncPrincipal.js.map +1 -0
  165. package/dist/esm/SyncProtocol.d.ts +592 -0
  166. package/dist/esm/SyncProtocol.d.ts.map +1 -0
  167. package/dist/esm/SyncProtocol.js +434 -0
  168. package/dist/esm/SyncProtocol.js.map +1 -0
  169. package/dist/esm/SyncRpcs.d.ts +92 -0
  170. package/dist/esm/SyncRpcs.d.ts.map +1 -0
  171. package/dist/esm/SyncRpcs.js +35 -0
  172. package/dist/esm/SyncRpcs.js.map +1 -0
  173. package/dist/esm/SyncServer.d.ts +184 -0
  174. package/dist/esm/SyncServer.d.ts.map +1 -0
  175. package/dist/esm/SyncServer.js +897 -0
  176. package/dist/esm/SyncServer.js.map +1 -0
  177. package/dist/esm/WorkspaceShare.d.ts +174 -0
  178. package/dist/esm/WorkspaceShare.d.ts.map +1 -0
  179. package/dist/esm/WorkspaceShare.js +208 -0
  180. package/dist/esm/WorkspaceShare.js.map +1 -0
  181. package/dist/esm/index.d.ts +91 -0
  182. package/dist/esm/index.d.ts.map +1 -0
  183. package/dist/esm/index.js +91 -0
  184. package/dist/esm/index.js.map +1 -0
  185. package/dist/esm/internal/Admission.d.ts +43 -0
  186. package/dist/esm/internal/Admission.d.ts.map +1 -0
  187. package/dist/esm/internal/Admission.js +80 -0
  188. package/dist/esm/internal/Admission.js.map +1 -0
  189. package/dist/esm/internal/CauseText.d.ts +55 -0
  190. package/dist/esm/internal/CauseText.d.ts.map +1 -0
  191. package/dist/esm/internal/CauseText.js +67 -0
  192. package/dist/esm/internal/CauseText.js.map +1 -0
  193. package/dist/esm/internal/JournalErrorCode.d.ts +23 -0
  194. package/dist/esm/internal/JournalErrorCode.d.ts.map +1 -0
  195. package/dist/esm/internal/JournalErrorCode.js +44 -0
  196. package/dist/esm/internal/JournalErrorCode.js.map +1 -0
  197. package/dist/esm/internal/PolicyOptions.d.ts +57 -0
  198. package/dist/esm/internal/PolicyOptions.d.ts.map +1 -0
  199. package/dist/esm/internal/PolicyOptions.js +70 -0
  200. package/dist/esm/internal/PolicyOptions.js.map +1 -0
  201. package/dist/esm/internal/ProtocolVersion.d.ts +17 -0
  202. package/dist/esm/internal/ProtocolVersion.d.ts.map +1 -0
  203. package/dist/esm/internal/ProtocolVersion.js +24 -0
  204. package/dist/esm/internal/ProtocolVersion.js.map +1 -0
  205. package/dist/esm/internal/ShareSigner.d.ts +173 -0
  206. package/dist/esm/internal/ShareSigner.d.ts.map +1 -0
  207. package/dist/esm/internal/ShareSigner.js +219 -0
  208. package/dist/esm/internal/ShareSigner.js.map +1 -0
  209. package/dist/esm/internal/SnapshotBoundary.d.ts +17 -0
  210. package/dist/esm/internal/SnapshotBoundary.d.ts.map +1 -0
  211. package/dist/esm/internal/SnapshotBoundary.js +68 -0
  212. package/dist/esm/internal/SnapshotBoundary.js.map +1 -0
  213. package/dist/esm/test/TestSocket.d.ts +55 -0
  214. package/dist/esm/test/TestSocket.d.ts.map +1 -0
  215. package/dist/esm/test/TestSocket.js +128 -0
  216. package/dist/esm/test/TestSocket.js.map +1 -0
  217. package/dist/esm/test/TestSync.d.ts +54 -0
  218. package/dist/esm/test/TestSync.d.ts.map +1 -0
  219. package/dist/esm/test/TestSync.js +76 -0
  220. package/dist/esm/test/TestSync.js.map +1 -0
  221. package/docs/README.md +156 -0
  222. package/docs/api.md +328 -0
  223. package/docs/concepts/authorization.md +117 -0
  224. package/docs/concepts/branches.md +195 -0
  225. package/docs/concepts/compaction.md +144 -0
  226. package/docs/concepts/replay-then-follow.md +129 -0
  227. package/docs/concepts/scopes-and-cursors.md +135 -0
  228. package/docs/guides/authorize-a-connection.md +174 -0
  229. package/docs/guides/follow-a-run.md +174 -0
  230. package/docs/guides/handle-a-compacted-run.md +158 -0
  231. package/docs/guides/list-workspace-runs.md +120 -0
  232. package/docs/guides/run-a-long-soak.md +80 -0
  233. package/docs/guides/serve-the-read-path.md +146 -0
  234. package/docs/guides/test-a-follower.md +273 -0
  235. package/docs/installation.md +92 -0
  236. package/docs/protocol.md +183 -0
  237. package/docs/quickstart.md +159 -0
  238. package/docs/troubleshooting.md +234 -0
  239. package/package.json +304 -3
  240. package/src/BranchCommands.ts +684 -0
  241. package/src/BranchIds.ts +76 -0
  242. package/src/BranchPresence.ts +512 -0
  243. package/src/BranchProjection.ts +308 -0
  244. package/src/BranchProtocol.ts +411 -0
  245. package/src/BranchRpcs.ts +163 -0
  246. package/src/BranchServer.ts +202 -0
  247. package/src/BranchShare.ts +297 -0
  248. package/src/RunCatalog.ts +308 -0
  249. package/src/SyncAuth.ts +146 -0
  250. package/src/SyncClient.ts +902 -0
  251. package/src/SyncError.ts +147 -0
  252. package/src/SyncPrincipal.ts +115 -0
  253. package/src/SyncProtocol.ts +594 -0
  254. package/src/SyncRpcs.ts +40 -0
  255. package/src/SyncServer.ts +1184 -0
  256. package/src/WorkspaceShare.ts +292 -0
  257. package/src/index.ts +107 -0
  258. package/src/internal/Admission.ts +119 -0
  259. package/src/internal/CauseText.ts +74 -0
  260. package/src/internal/JournalErrorCode.ts +46 -0
  261. package/src/internal/PolicyOptions.ts +97 -0
  262. package/src/internal/ProtocolVersion.ts +28 -0
  263. package/src/internal/ShareSigner.ts +300 -0
  264. package/src/internal/SnapshotBoundary.ts +91 -0
  265. package/src/test/TestSocket.ts +192 -0
  266. package/src/test/TestSync.ts +113 -0
package/docs/api.md ADDED
@@ -0,0 +1,328 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every public export of @smthrs/sync: the wire protocol, the read-path RPC group, the fail-closed server, the replay-then-follow client, the run catalog, the two capability authorities, and the branch collaboration modules."
4
+ ---
5
+
6
+ ```ts
7
+ import { JournalEvent } from "@smthrs/journal"
8
+ import { RunCatalog, SyncClient, SyncServer } from "@smthrs/sync"
9
+ import { Effect, Layer } from "effect"
10
+
11
+ const serverLayer = SyncServer.layer.pipe(Layer.provide(RunCatalog.layerStatic([])))
12
+
13
+ const follow = Effect.gen(function*() {
14
+ const sync = yield* SyncClient.SyncClient
15
+ return sync.subscribe({ scope: { _tag: "Run", runId: "build-42" as JournalEvent.RunId }, cursors: [] })
16
+ })
17
+ ```
18
+
19
+ ## Entry points
20
+
21
+ | Import | Source | Platform |
22
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -------- |
23
+ | `@smthrs/sync` | [src/index.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/index.ts) | any |
24
+ | `@smthrs/sync/test/TestSync` | [src/test/TestSync.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSync.ts) | Node |
25
+ | `@smthrs/sync/test/TestSocket` | [src/test/TestSocket.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSocket.ts) | any |
26
+
27
+ The root is browser safe, and the package's own bundle check holds it that
28
+ way. The signing paths call Web Crypto directly for that reason, so one module
29
+ serves Node and the browser. `TestSync.layerTest` binds the Node SQLite test
30
+ journal, which is why it is a separate subpath.
31
+
32
+ ## Authorization
33
+
34
+ Fail closed along two boundaries, both consulted per request:
35
+
36
+ - **Branch runs.** A run whose id maps to a shared branch is visible only when
37
+ the request's share capability verifies for that branch. An explicitly scoped
38
+ branch read without one fails; a workspace listing excludes the branch runs
39
+ the caller's capability does not cover. Without a `BranchShare` in scope
40
+ every branch run is closed.
41
+ - **Non-branch runs and workspace listings.** Visible only to the workspace
42
+ principal (`SyncPrincipal`), whose default is anonymous. Over RPC,
43
+ `SyncAuth.layer` establishes that principal by verifying the
44
+ `WorkspaceShare` capability presented in the `flows-sync-workspace` header.
45
+ A header that is present but malformed, forged, expired, or signed by an
46
+ unknown key is refused outright rather than downgraded to anonymity.
47
+
48
+ A SUBSCRIPTION is additionally bounded in time. It is authorized once, when it
49
+ opens, so the signed expiry travels with the identity (`SyncPrincipal.Workspace.expiresAtMs`,
50
+ or the branch capability's own `expiresAtMs`) and the stream ends with
51
+ `unauthorized` when that moment arrives. An in-process owner that provides
52
+ `SyncPrincipal` itself presented no credential and has no deadline.
53
+
54
+ Both authorities sign a length-prefixed encoding of their claims under
55
+ HMAC-SHA-256, led by a scheme label, so neither's signature can be replayed as
56
+ the other's under a shared secret. Length prefixes count UTF-8 bytes, and a
57
+ claim set that does not survive UTF-8 (an unpaired surrogate) is refused with
58
+ `invalid_request` rather than signed. Secrets are `Redacted` on both sides and
59
+ must be at least 32 bytes of UTF-8, or the keyring is refused with
60
+ `invalid_request`. Both carry a `kid` inside the signed claims, so keys rotate without
61
+ invalidating capabilities minted under a retired one. Each authority takes its
62
+ own keyring: `BranchShare.layerConfig` reads `SMITHERS_SYNC_BRANCH_SECRET` and
63
+ `SMITHERS_SYNC_BRANCH_KEY_ID`, `WorkspaceShare.layerConfig` reads
64
+ `SMITHERS_SYNC_SECRET` and `SMITHERS_SYNC_KEY_ID`.
65
+
66
+ Both authorities decode their mint request before signing it, so an empty id
67
+ or a non-positive `ttlMs` from an in-process caller is refused with
68
+ `invalid_request` rather than crashing the operation. `BranchCommands.submission`
69
+ returns an `Effect` for the same reason: a command name the schema forbids is a
70
+ refusal, not a throw.
71
+
72
+ ## Bounds
73
+
74
+ The bounds cap what is in flight at once: entries per page, frames per
75
+ subscription, journal reads open per workspace subscription, bytes per frame.
76
+ What one follower holds open is therefore a function of the configured bound,
77
+ not of how far behind it has fallen. Two things still scale with the workspace.
78
+ A workspace subscription keeps a served position for every run it has ever
79
+ covered, including a run the catalog later drops, so its bookkeeping is O(runs
80
+ covered). A catalog-wide round, which runs at open, on an announcement, and
81
+ once per `tailIntervalMs`, reads one page and two generations per covered run,
82
+ so its work is O(runs listed) with `concurrency` reads open at a time. A wake
83
+ for an entry committed in this process reads only the runs it named. Size
84
+ `concurrency` and `tailIntervalMs` for the catalog, not only for the follower.
85
+
86
+ | Bound | Default | What it caps |
87
+ | ------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
88
+ | `SyncProtocol.maxReadLimit` | 1024 | Entries one `Sync.Read` may ask for. Over the limit is refused at the wire; an in-process caller is clamped. |
89
+ | `SyncProtocol.maxSubscribeCredit` | 4096 | Frames one `Sync.Subscribe` may hold open. Zero is refused rather than served as an empty stream. |
90
+ | `SyncServer.Options.concurrency` | `SyncServer.defaultConcurrency` (64) | Journal reads one workspace subscription holds open at once. A round reads one bounded page per run it visits, so a busy run wakes the next round instead of holding its slot. |
91
+ | `SyncServer.Options.tailIntervalMs` | `SyncServer.defaultTailIntervalMs` (1000) | Milliseconds a workspace subscription waits before revisiting every covered run when nothing wakes it, and the longest that local wakes may defer that catalog-wide round. |
92
+ | `SyncServer.Options.maxFrameBytes` | `SyncProtocol.defaultMaxFrameBytes` (2 MiB) | Summed encoded entries of one read page or subscription frame. |
93
+ | `SyncClient.SubscribeOptions.credit` | `SyncClient.defaultCredit` (256) | Frames one subscription round carries before the follow replenishes the window by resubscribing from its acknowledged cursors. |
94
+ | `SyncClient.Options.bootstrapLimit` | `SyncClient.defaultBootstrapLimit` (256) | Entries one catch-up page asks for. |
95
+ | `BranchCommands.Options.maxCommandBytes` | `BranchCommands.defaultMaxCommandBytes` (1 MiB) | Encoded size of one command submission, refused before anything is appended. |
96
+ | `BranchCommands.Options.ledgerCapacity` | `BranchCommands.defaultLedgerCapacity` (4096) | Receipts one branch keeps in memory. The journal's producer identity is the durable dedupe, so an evicted receipt costs a round trip and never correctness. |
97
+ | `BranchCommands.Options.hydrationLimit` | `BranchCommands.defaultHydrationLimit` (4096) | Entries one branch's first-touch hydration reads before it stops, so a long history is not charged to the next writer's latency. What the walk misses, the journal answers. |
98
+ | `BranchCommands.Options.branchIdleMs` | `BranchCommands.defaultBranchIdleMs` (24 h) | Untouched time before a branch's in-memory state is dropped. The next submission re-hydrates it from the journal, as after a restart. |
99
+ | `BranchPresence.PresenceOptions.maxParticipants` | `BranchPresence.defaultMaxParticipants` (256) | Participants one branch may hold at once; a further announce is refused with `backpressure`. |
100
+ | `RunCatalog.MemoryOptions.changesCapacity` | `RunCatalog.defaultChangesCapacity` (1024) | Announcements a stalled `changes` subscriber may fall behind by; the oldest slide out. |
101
+ | `RunCatalog.PollingOptions.intervalMs` | `RunCatalog.defaultPollIntervalMs` (1000) | Milliseconds between reads of the durable run set: one bounded query per interval per composition, not per subscriber. |
102
+ | `BranchPresence.PresenceOptions.changesCapacity` | `BranchPresence.defaultChangesCapacity` (256) | Roster notifications a stalled `changes` subscriber may fall behind by; the oldest slide out. |
103
+
104
+ Every numeric option is validated where it enters. A policy belongs to the
105
+ constructor that takes it: `SyncServer.makeLiveWith`, `BranchCommands.makeLiveWith`,
106
+ `SyncClient.makeWith`, `BranchPresence.makeMemory`, `RunCatalog.makeMemory` and
107
+ `RunCatalog.makePolling`, plus the `layerWith`, `layerMemory` and `layerPolling`
108
+ layers over them, fail with `invalid_request` when a value is not a positive
109
+ safe integer, so a bad policy fails the composition instead of quietly
110
+ disabling the comparison it configures. The plain `make`, `makeLive` and
111
+ `layer` forms carry the defaults, which are valid by construction, and cannot
112
+ fail on a policy. A count a REQUEST carries rather than a policy, meaning
113
+ `Sync.Read`'s `limit` and `SyncClient.SubscribeOptions.credit`, is checked
114
+ where the request is built.
115
+
116
+ Both change feeds slide rather than block: a publisher never waits on a stalled
117
+ subscriber and never grows the process on its behalf. Neither feed is a source
118
+ of truth. `RunCatalog.list` and `BranchPresence.list` are the authoritative
119
+ state, and every reader re-lists on a cadence of its own: a workspace
120
+ subscription reconciles its covered run set against `RunCatalog.list` on every
121
+ round, and a roster watch re-lists once per `BranchPresence` lease, capped at one second. A dropped
122
+ notification therefore costs latency and never state.
123
+
124
+ ## Read path
125
+
126
+ `SyncServer.Service` has `read(request)`, `subscribe(request)`, and `snapshot(request)`.
127
+ `makeLiveWith(options)` and `layerWith(options)` take the `Options` policy
128
+ above; `makeLive` and `layer` use the defaults. The live layer requires
129
+ `Journal` and `RunCatalog`.
130
+
131
+ `SyncServer.SnapshotSource` is an optional host service with
132
+ `read(SnapshotRequest): Effect<Snapshot, SyncError>`. It provides only public
133
+ projections, never raw unredacted execution checkpoints. The server authorizes
134
+ the run/branch before invoking it, checks expiry before returning data, and
135
+ refuses missing providers. Providers must select the exact requested lineage,
136
+ projection and version and retain state covering the requested sequence.
137
+
138
+ `SyncClient.Service.snapshot(request)` fetches that projection through
139
+ `Sync.Snapshot` without applying state or advancing cursors. `SnapshotRequest`
140
+ and `Snapshot` in `SyncProtocol` carry protocol version 1, run/lineage identity,
141
+ projection name/version, minimum or actual sequence, and response JSON state.
142
+ Both ends validate identities and the full response's encoded UTF-8 byte limit
143
+ using `maxFrameBytes`. Invalid or stale state is refused, not coerced or skipped.
144
+
145
+ `SyncClient.Service.progress` returns `SyncProtocol.Progress`: two cursor sets,
146
+ `delivered` and `applied`. `delivered` is a delivery bookmark only. `applied`
147
+ advances only after successful application or restoration; an applying subscription
148
+ uses the shared applied map when choosing its start position. Use one client
149
+ per projection and persist projection state and its cursor in one transaction.
150
+
151
+ A read shares its page across the runs it covers and stops at the first of three
152
+ bounds: the request's `limit`, the frame ceiling, or the durable tail of every
153
+ covered run, which is the only case that reports `done: true`. Every covered run
154
+ takes a share of the budget before any run takes a second helping, and the
155
+ budget the shares leave unspent is offered back in run order, so a run with a
156
+ backlog takes the larger part of a page but never all of it. Filling in run
157
+ order instead let a producer that stayed one page ahead take every slot of every
158
+ page, so `done` never became true and a bootstrapping follower never reached the
159
+ runs behind it. The frame ceiling is a page budget rather than a verdict on the
160
+ read, so a page whose entries sum past it reports `done: false`; only a SINGLE
161
+ entry whose own encoded size exceeds the ceiling is refused with
162
+ `frame_too_large`, because no page can ever carry it.
163
+
164
+ Cursors are unique per run. A request that names one run twice is refused with
165
+ `invalid_request` on both `read` and `subscribe`, because the read position and
166
+ the echoed response state would otherwise disagree about where the page began.
167
+
168
+ ## Follow path
169
+
170
+ `SyncClient.SyncClient` is the browser-safe service tag; `SyncClient.Sync` is
171
+ its deprecated former name. `make({ client })` adapts an Effect RPC client and
172
+ `layer` derives that client from `RpcClient.Protocol`.
173
+ A subscription replays through `Sync.Read` until the server reports `done`,
174
+ then follows through `Sync.Subscribe` in credit windows, replenishing each
175
+ window by resubscribing from its matching delivered or applied progress.
176
+
177
+ A frame or page whose encoded entries exceed `maxFrameBytes` is refused with
178
+ `frame_too_large`. Out-of-scope entries, repeated or reordered sequences, and
179
+ missing response generations fail with `protocol_violation` before delivery.
180
+ Each run represented in a `Sync.Read` page's entries must have exactly one
181
+ cursor with an explicit generation matching the request, and every entry must
182
+ be above the requested cursor. Live frames instead drop already-covered
183
+ entries and deliver only the suffix above the cursor. An incomplete bootstrap
184
+ page with no entries or a completed subscription window with no frames fails
185
+ with `protocol_violation` instead of reopening immediately.
186
+
187
+ Transport, authentication, and reconnect are handled here rather than left to
188
+ the application. A live follow that loses its transport reconnects under
189
+ exponential backoff capped at five seconds, resuming from the subscription's
190
+ progress; gaps, authorization refusals, and server closes propagate to the
191
+ consumer instead of retrying.
192
+
193
+ A delivery bookmark names what was delivered. `SubscribeOptions.apply`
194
+ additionally records `progress.applied`: the callback runs to success before
195
+ that cursor moves, so a failed application is retried by the next applying
196
+ subscription. A delivery-only subscription cannot acknowledge application.
197
+
198
+ `apply` and `onResync` fail with the consumer's own error types, and
199
+ `subscribe` returns `Stream<Entry, SyncError | SyncGapError | EApply | EResync>`.
200
+ A consumer failure keeps its own type, so it never borrows a wire code and
201
+ never passes `SyncError.is`.
202
+
203
+ ## Compaction and resync
204
+
205
+ Compaction deletes a run's entries below a checkpoint, so a cursor under that
206
+ floor names history that no longer exists. The server maps the journal's
207
+ `compacted` failure onto its own code with the run id the read was issued for,
208
+ and `SyncError.resync` carries `{ runId, checkpointSeq, generation }`. The client
209
+ fails closed unless `onResync` restores a snapshot and returns its
210
+ `{ runId, afterSeq }`. It validates that receipt before advancing to the actual
211
+ restored sequence, in the refusal's generation unless the receipt names its own. A
212
+ checkpoint at or below what the subscription already covers cannot move the
213
+ cursor forward, so it stays a failure rather than a retry of the same refusal.
214
+
215
+ :::warning
216
+ The resync moves a cursor, not state. The entries below `checkpointSeq` are
217
+ gone from the journal. `SyncClient.snapshot` can fetch a configured public
218
+ projection, but does not apply it. `SubscribeOptions.onResync` is the seam a consumer fills that hole
219
+ through: it runs BEFORE the cursor moves and must return a valid restored cursor, so a failure leaves
220
+ the cursor where it was and nothing is skipped silently. A Node follower reads
221
+ the prefix from `Journal.latestCheckpoint(runId)` and applies it. With no handler,
222
+ the original refusal is preserved. A consumer that cannot restore the prefix
223
+ must not return an applied receipt for it. Durable consumers must commit snapshot state and
224
+ their durable cursor in their own transaction; the client cursor is in memory.
225
+ :::
226
+
227
+ ## Errors
228
+
229
+ `ErrorCode` includes `unsupported` (since 1.0.0) for unavailable host
230
+ operations or presence transport; it does not mean an empty roster.
231
+
232
+ `SyncError` carries a stable `code` from `ErrorCode`, a `message`, an optional
233
+ bounded `cause` string, and a `resync` that is set only on `compacted`.
234
+ `SyncGapError` reports a server interval that starts beyond the client's
235
+ covered cursor.
236
+
237
+ `cause` is a STRING and never the host object that failed. `SyncError` is the
238
+ declared error schema of every RPC in both groups, so what it carries reaches a
239
+ remote follower that may hold nothing but a branch share link: a journal
240
+ failure crosses as its stable journal code, never as the driver's own sentence,
241
+ and the public message names the run rather than the storage fault.
242
+
243
+ `SyncError.is` is a structural check: the tag, a declared `code`, a string
244
+ `message`, and a `resync` only alongside `compacted`. It deliberately does not
245
+ require the prototype, because every value that reaches it has crossed a
246
+ boundary that rebuilds it.
247
+
248
+ ## Branch collaboration
249
+
250
+ `BranchPresence.Service.presenceOn(request)` (since 1.0.0) returns
251
+ `"unknown" | "present" | "empty"`, authorizing read access first. It stays
252
+ `unknown` for one configured `leaseMs` after startup so live participants can
253
+ re-announce, and while any required heartbeat source is unavailable. Treat
254
+ `unknown` as present for safe-idle decisions.
255
+
256
+ `PresenceOptions.sourcesReady(branchId)` (since 1.0.0) supplies an
257
+ `Effect<boolean>` readiness check for authorization, revocation, bridge and
258
+ session sources. It defaults to unavailable.
259
+
260
+ `BranchPresence.makeNoop()` and `layerNoop` fail announce, leave and list with
261
+ `unsupported`; `presenceOn` returns `unknown`. Consequently
262
+ `BranchServer` roster reads and watches also fail with `unsupported` under
263
+ `layerNoop`, rather than reporting an empty roster.
264
+
265
+ A branch is one shared live document whose durable state is exactly one journal
266
+ run (`BranchProtocol.branchRunId`), so multiplayer reuses the canonical `seq`,
267
+ cursors, gap detection, and resumable follow rather than introducing a second
268
+ source of truth. Presence is a lease and is never journalled. Commands are
269
+ admitted through a client-minted idempotency key whose exactly-once constraint
270
+ is the journal's own producer identity, so two servers racing one command
271
+ collide durably inside the write transaction.
272
+
273
+ `BranchRpcs` is the wire group and `BranchServer.layerHandlers` projects the
274
+ branch services onto it. The payload schemas ARE the service schemas, so the
275
+ wire and the services cannot drift about what a legal message is.
276
+
277
+ :::warning
278
+ Branch collaboration ships unserved at 1.0.0-rc.0.
279
+ [`@smthrs/gateway`](/api/gateway) mounts `SyncRpcs` on `POST /sync` and
280
+ `/sync/ws`; nothing outside this package's own tests mounts `BranchRpcs`, so
281
+ the seven branch procedures have no integration against a real gateway yet.
282
+ Treat the branch modules as a library surface pending a host, not as a served
283
+ endpoint.
284
+ :::
285
+
286
+ ## Test helpers
287
+
288
+ | Export | Source | Notes |
289
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
290
+ | `TestSync.layerTest`, `layerTrustAllAsOwner`, `layerNoop`, `connect` | [src/test/TestSync.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSync.ts) | a real server and client over an in-memory socket pair |
291
+ | `TestSocket.makePair`, `Pair`, `TestFaults`, `FrameFilter` | [src/test/TestSocket.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSocket.ts) | fault-injecting socket pair |
292
+
293
+ Subscription fan-out is held to budgets, not only to frame assertions. The
294
+ package's soak suite runs five concurrent workspace subscribers and requires an
295
+ identical frame set from each, drains 200 subscribe and complete cycles and
296
+ requires every per-run journal stream to be released afterwards, and soaks 200
297
+ five-subscriber rounds under a retained-heap budget. A regression that retains
298
+ per-subscriber state satisfies every frame assertion elsewhere, so those are
299
+ the checks that catch it.
300
+
301
+ The optional [long soak tier](./guides/run-a-long-soak.md) adds repeated
302
+ post-warmup resource samples, growth slopes, real TCP reconnects, on-disk
303
+ compaction and checkpoint retention, and a stalled subscriber. It writes a
304
+ machine-verifiable JSON artifact; ordinary PR gates do not start a timed run.
305
+
306
+ ### Rewind generations
307
+
308
+ `ReadRequest` and `SubscribeRequest` carry `protocolVersion: 1`. The server
309
+ refuses a missing or different version with typed `protocol_violation`.
310
+ Server cursors and entry frames always carry a nonnegative `generation`,
311
+ including zero; a client refuses missing response generations with the same
312
+ error before delivery. Persisted request cursors may omit generation for zero.
313
+ Persist the returned
314
+ generation alongside `afterSeq`. A mismatch raises `lineage_changed` before
315
+ sequence deduplication. Server errors carry
316
+ `rewind: { runId, generation, afterSeq }`, where `afterSeq` is the archive
317
+ boundary (`-1` for a full reset). Rebuild the projection from the current
318
+ retained history through that boundary, create a fresh client, and resume from
319
+ that position. Idle subscriptions check generations at
320
+ `tailIntervalMs`, as well as around journal reads.
321
+
322
+ ## Presence coordinates
323
+
324
+ `BranchProtocol.PresenceWhere` validates file coordinates (a nonempty path and
325
+ optional positive integer line), a terminal id, a step label, or a branch.
326
+ `Participant.where` is optional so recorded frames without coordinates decode.
327
+ A Leave with `sessionId` removes that seat; without it, Leave removes every seat
328
+ for the participant, after verifying ownership of all matching seats.
@@ -0,0 +1,117 @@
1
+ ---
2
+ title: "Authorization"
3
+ description: "The two fail-closed boundaries of the read path, the workspace principal and the branch capability that cross them, and why a subscription carries its own expiry."
4
+ ---
5
+
6
+ Authorization in `@smthrs/sync` fails closed along two boundaries, and both are
7
+ consulted on every request. Neither has a trusted pass-through: code that never
8
+ authenticates reads nothing.
9
+
10
+ ## Boundary one: non-branch runs and workspace listings
11
+
12
+ Every run whose id does not map to a branch is visible only to the workspace
13
+ principal. `SyncPrincipal` is a `Context.Reference` whose default value is
14
+ `SyncPrincipal.anonymous`, which is what makes the default posture closed
15
+ rather than open. A workspace-scoped request and a run-scoped request for an
16
+ engine run are both refused with `unauthorized` for an anonymous caller.
17
+
18
+ There are exactly two ways to become the workspace principal:
19
+
20
+ - **Over a transport.** `SyncAuth.layer` verifies the `WorkspaceShare`
21
+ capability presented in the `flows-sync-workspace` request header and
22
+ installs the resulting principal. A request with no header runs as anonymous.
23
+ A header that is present but malformed, forged, expired, or signed by an
24
+ unknown key is refused outright rather than downgraded to anonymity, because
25
+ presenting a credential is a claim and a false claim is not the same thing as
26
+ no claim.
27
+ - **In process.** A caller that already owns the journal provides
28
+ `SyncPrincipal.layerWorkspace(capabilityId)` itself. That is the sanctioned
29
+ bypass, and it is never a transport's to take.
30
+
31
+ Every decode failure of the header folds to the same `unauthorized` refusal, so
32
+ the header is not a parsing oracle for an attacker probing its shape.
33
+
34
+ ## Boundary two: branch runs
35
+
36
+ A run whose id carries the branch prefix is visible only when the request's own
37
+ `capability` verifies for that branch through `BranchShare`. The capability
38
+ travels in the request payload, not in a header, because a share link is a
39
+ per-branch credential rather than a per-connection one.
40
+
41
+ The two boundaries compose rather than override each other:
42
+
43
+ - An explicitly scoped branch read with no capability fails.
44
+ - A workspace listing excludes the branch runs the caller's capability does not
45
+ cover, so one share link never leaks another branch's log.
46
+ - With no `BranchShare` service in scope at all, every branch run is closed.
47
+ - A connection holding only a branch share link can never read an engine run,
48
+ because that is boundary one's question and the link does not answer it.
49
+
50
+ ## What a capability is
51
+
52
+ Both authorities mint the same shape: a claim set plus an HMAC-SHA-256
53
+ signature over a length-prefixed canonical encoding of it, compared in constant
54
+ time. A capability names one subject, one access level (`read` or `write`), and
55
+ one expiry. The holder cannot widen it because widening changes the claims and
56
+ therefore the signature.
57
+
58
+ Three details make that hold up:
59
+
60
+ - **Length prefixes.** Without them a subject id ending in the separator could
61
+ be re-cut into a different, still validly signed claim set. Prefixes count
62
+ UTF-8 bytes, and a claim set that does not survive UTF-8 encoding, such as
63
+ one carrying an unpaired surrogate, is refused with `invalid_request` rather
64
+ than signed.
65
+ - **A scheme label leading the encoding.** A branch signature can never verify
66
+ as a workspace signature, even if one secret is misconfigured into both
67
+ authorities.
68
+ - **`Redacted` secrets.** A keyring never holds a plain string, so a log, a
69
+ span, or an inspection of the options object cannot render it.
70
+
71
+ Both authorities carry a `kid` inside the signed claims. The verifier both
72
+ selects the right key and refuses a capability whose key name was swapped after
73
+ minting, which is what lets a deployment rotate keys while capabilities minted
74
+ under a retired key are still outstanding. The keyrings are separate:
75
+ `BranchShare.layerConfig` provisions the branch authority from
76
+ `SMITHERS_SYNC_BRANCH_SECRET` and `SMITHERS_SYNC_BRANCH_KEY_ID`, so rotating
77
+ one credential leaves the other's outstanding capabilities alone. See
78
+ [Authorize a connection](../guides/authorize-a-connection.md).
79
+
80
+ ## A subscription carries its own deadline
81
+
82
+ A signed expiry is this package's only revocation mechanism, and a subscription
83
+ is not a sequence of requests. It is authorized once, at open, and then
84
+ streams. Without a deadline travelling with the identity, a holder of an
85
+ expired capability could keep reading for as long as it declined to disconnect.
86
+
87
+ So the expiry is on the principal itself, as
88
+ `SyncPrincipal.Workspace.expiresAtMs`, or on the branch capability's own
89
+ `expiresAtMs`, and the stream ends with `unauthorized` when that moment
90
+ arrives. An in-process owner that provided `SyncPrincipal` directly presented no
91
+ credential, so its `expiresAtMs` is `Infinity` and no deadline applies.
92
+
93
+ ## Two stages on the branch side
94
+
95
+ Opening a branch is a bootstrap: no capability for it can exist yet.
96
+ `Branch.CreateBranch` therefore requires an authenticated workspace principal
97
+ supplied by the same `SyncAuth` middleware, and its handler refuses any other
98
+ principal. Every later branch procedure carries its own capability and
99
+ authorizes through `BranchShare`, so past the bootstrap the capability is the
100
+ credential.
101
+
102
+ `Branch.MintShare` requires write access to the branch, so only a collaborator
103
+ can invite one, and the minted link never outlives the capability it was minted
104
+ from. The handler rechecks the parent expiry after generating the child ID.
105
+ It passes the parent's absolute expiry as `BranchShare.mint`'s
106
+ `maxExpiresAtMs`; the authority clamps the requested TTL against that ceiling
107
+ using the clock read that sets `issuedAtMs`. If the ceiling has elapsed,
108
+ minting fails with `unauthorized`.
109
+
110
+ ## Related pages
111
+
112
+ - [Authorize a connection](../guides/authorize-a-connection.md): minting,
113
+ presenting, and rotating a workspace capability.
114
+ - [Branch collaboration](./branches.md): what a branch capability authorizes
115
+ beyond reading.
116
+ - [Troubleshooting](../troubleshooting.md): the `unauthorized` symptoms and
117
+ what each one means.
@@ -0,0 +1,195 @@
1
+ ---
2
+ title: "Branch collaboration"
3
+ description: "A branch is one shared live document whose durable state is exactly one journal run: how commands are admitted exactly once, why presence is never journalled, and what the projection guarantees."
4
+ ---
5
+
6
+ :::warning
7
+ Branch collaboration ships unserved at 1.0.0-rc.0.
8
+ [`@smthrs/gateway`](/api/gateway) mounts `SyncRpcs` on `POST /sync` and
9
+ `/sync/ws`; nothing outside this package's own tests mounts `BranchRpcs`, so
10
+ the seven branch procedures have no integration against a real host yet. Treat
11
+ these modules as a library surface pending a host, not as a served endpoint.
12
+ :::
13
+
14
+ A branch is one shared live document that several people edit at once. It has
15
+ no store of its own. Its durable state is exactly one journal run, and
16
+ `BranchProtocol.branchRunId` is the only mapping:
17
+
18
+ ```ts
19
+ import * as BranchProtocol from "@smthrs/sync/BranchProtocol"
20
+
21
+ const branchId = "design-review" as BranchProtocol.BranchId
22
+ const runId = BranchProtocol.branchRunId(branchId)
23
+ ```
24
+
25
+ That single decision is what the rest of the design rests on. Every replication
26
+ guarantee the read path already provides carries over unchanged: the canonical
27
+ per-run `seq`, exclusive cursors, gap detection, resumable follow. Multiplayer
28
+ introduces no second source of truth, and a branch is followed with the same
29
+ `SyncClient.subscribe` call any other run is followed with.
30
+
31
+ `BranchProtocol.branchOfRunId` reverses the mapping, and returns `null` for a
32
+ run that is not a branch. The bare prefix with nothing after it is a non-branch
33
+ run rather than a branch with an empty id, because `BranchId` is a branded
34
+ non-empty string and branding `""` would hand a value the brand forbids to
35
+ `share.verify`.
36
+
37
+ ## Commands are admitted exactly once
38
+
39
+ Three things look identical from the server's side: an optimistic client
40
+ re-sending after a timeout, a reconnecting client flushing its outbox, and two
41
+ people pressing the same button at once. All three must produce exactly one
42
+ durable command.
43
+
44
+ The client mints a `CommandId` once per user intent and reuses it across
45
+ optimistic application, retransmission, and concurrent submission. That is what
46
+ makes it an idempotency key rather than a correlation id.
47
+
48
+ The constraint is durable, not process-local. Every append carries the journal
49
+ producer identity `(branch run, commandSourceId(commandId), commandSourceSeq)`,
50
+ which the journal enforces inside its own write transaction. Two independently
51
+ constructed servers racing the same command therefore collide in the journal:
52
+ one appends, and the other receives a duplicate receipt or an idempotency
53
+ conflict and resolves the canonical sequence by replaying the branch.
54
+
55
+ `commandSourceSeq` is fixed at 0 and supplied explicitly. Letting the journal
56
+ allocate a sequence would hand a resubmission after a lost response a fresh
57
+ identity, and therefore a second durable append.
58
+
59
+ A `CommandReceipt` reports `admitted` or `duplicate`, and both are successes.
60
+ `duplicate` names the canonical sequence the original submission already
61
+ occupies, so a retransmitting client settles its optimistic row instead of
62
+ executing the command twice.
63
+
64
+ `BranchCommands` keeps an in-memory receipt ledger, a per-branch admission
65
+ permit, and a replay cursor. All three are a fast path: they answer known
66
+ duplicates without a journal write and keep a restarted server from
67
+ re-executing history. Correctness never depends on them, which is what makes it
68
+ safe to bound them. See the ledger, hydration, and command size bounds in the
69
+ [API reference](../api.md).
70
+
71
+ ## One event type, one decoder
72
+
73
+ A branch journal has exactly one durable event shape,
74
+ `BranchProtocol.CommandEvent`. Chat messages, edits, and flow launches are all
75
+ commands, so the projection has exactly one decoder. `branch.say` is the
76
+ command that appends a chat message; a command's `target` names the shared
77
+ field it durably edits and is `""` for one that only appends.
78
+
79
+ A command's `participantId` is whatever the submitting client claimed.
80
+ `BranchCommands.submit` stamps the verified `capabilityId` of the share
81
+ capability that sent it onto the durable payload, and the projection carries it
82
+ on every message, applied command, and field. Attribute a command by its
83
+ `capabilityId`; a row admitted before the field existed decodes it as `""`.
84
+
85
+ Sync admission and command hydration validate known command records before
86
+ advancing progress. Malformed commands and foreign run/branch identities fail
87
+ typed; they cannot become a partial hydration or a new durable write. Hydration
88
+ stages a bounded receipt map and commits it with the replay cursor only after
89
+ all pages in that replay succeed. Existing decoding defaults for absent `args`
90
+ and `target` remain in force. Malformed retained records require explicit
91
+ repair by their authority; this package never rewrites them or silently treats
92
+ them as successful commands.
93
+
94
+ `BranchProjection.apply` and `project` are pure folds over admitted canonical
95
+ records, not admission APIs. Their convergence rules permit redelivery and
96
+ reordering of that canonical set. Use the validated sync read path before
97
+ folding transport or host input. The read path rejects malformed known records
98
+ before those pure folds can ignore them.
99
+
100
+ ## Presence is a lease, and is never journalled
101
+
102
+ A roster is a lease table. Every announcement extends a lease, and a
103
+ participant that stops announcing, because the tab closed or the process died,
104
+ ages out without anyone reporting the disconnect. An announcement is idempotent
105
+ and the same shape serves join, heartbeat, and cursor movement, so a client that
106
+ reconnects simply announces again.
107
+
108
+ Writing presence to the journal would make "who was looking at this" part of the
109
+ durable, replayable history of a run. That is both unbounded and wrong:
110
+ replaying a branch must not resurrect a stranger's caret.
111
+
112
+ Announcing requires write access, so a read-only share link may watch the roster
113
+ but never appears on it, and a shared read link cannot be used to impersonate a
114
+ collaborator. A live participant is bound to the verified `capabilityId` that
115
+ first announced it: an announce or leave for that `participantId` under any
116
+ other capability is refused with `unauthorized` until the lease lapses, so one
117
+ write link cannot rename, move, or evict another link's participant. Holders of
118
+ the same link share its `capabilityId` and are not told apart. A branch holds at most
119
+ `BranchPresence.defaultMaxParticipants` participants at once; a further announce
120
+ is refused with `backpressure`.
121
+
122
+ `BranchPresence.list` is authoritative and `changes` is only a low-latency wake.
123
+ Each announcement or list drops that branch's expired leases and sweeps up to
124
+ 16 branch rosters for expired participants, deleting empty maps. The sweep
125
+ resumes where the previous call stopped, so activity on other branches reclaims
126
+ abandoned rosters. An idle registry retains expired storage until activity
127
+ resumes; no timer fiber is needed. Both operations return detached participant
128
+ and cursor values, so callers cannot mutate the stored roster through a result.
129
+
130
+ A watcher re-lists once per lease as well as on every change, because a lapsed
131
+ lease publishes nothing: a watch driven by change events alone would never
132
+ observe the last participant leaving.
133
+
134
+ ## The projection converges
135
+
136
+ `BranchProjection` folds journal entries into what two people looking at the
137
+ same branch must agree on: the message timeline, the applied commands, and the
138
+ current value of each durably edited field.
139
+
140
+ The fold is order-independent over the canonical entry set, which makes
141
+ convergence a property rather than a hope. Any two clients that have applied the
142
+ same set of entries hold the same state, whatever order their transports
143
+ delivered frames in and however many times a frame arrived. Three guards do the
144
+ work:
145
+
146
+ - an entry from another branch's run is ignored, so a mis-routed frame cannot
147
+ leak into a projection;
148
+ - an entry already folded, whether the cursor tip redelivered or a command id
149
+ already applied, is ignored, so an at-least-once transport cannot double-apply
150
+ a user action;
151
+ - an entry below the cursor whose command was never applied is folded into its
152
+ canonical `seq` position, so out-of-order delivery converges instead of
153
+ permanently dropping the late entry.
154
+
155
+ Two durable edits of the same field are resolved by highest canonical sequence,
156
+ with a lexicographic `participantId` tie-break. The rule is deliberately not
157
+ wall-clock based: client clocks disagree, and a merge rule that depends on them
158
+ is not deterministic.
159
+
160
+ ## The wire group
161
+
162
+ `BranchRpcs` carries seven procedures: `Branch.CreateBranch`,
163
+ `Branch.MintShare`, `Branch.Submit`, `Branch.Announce`, `Branch.Leave`,
164
+ `Branch.Roster`, and `Branch.WatchRoster`. `BranchServer.layerHandlers` projects
165
+ the branch services onto them. Five procedures forward to the service that owns
166
+ their boundary and add nothing: `Branch.Submit` to `BranchCommands`,
167
+ `Branch.Announce`, `Branch.Leave`, `Branch.Roster`, and `Branch.WatchRoster`
168
+ to `BranchPresence`. For those, an in-process caller and a remote caller face
169
+ the same rules.
170
+
171
+ Two procedures enforce policy in the adapter itself, on top of what
172
+ `BranchShare` checks:
173
+
174
+ - `Branch.CreateBranch` refuses any principal that is not an authenticated
175
+ workspace with `unauthorized` before it mints the first capability;
176
+ - `Branch.MintShare` verifies the presented capability for write access to the
177
+ branch, refuses an expired parent with `unauthorized`, and passes the parent's
178
+ expiry as `maxExpiresAtMs` so the child never outlives it.
179
+
180
+ `BranchShare.mint` performs none of those checks. It decodes its request, so a
181
+ malformed id or ttl is refused with `invalid_request`, but an in-process host
182
+ that mints through the service directly must gate the principal and the parent
183
+ capability itself or it hands out branches and links the wire would refuse.
184
+
185
+ The payload schemas **are** the service schemas rather than copies of them, so
186
+ the wire and the services cannot drift about what a legal message is.
187
+
188
+ For the two-stage authorization the group uses, see
189
+ [Authorization](./authorization.md).
190
+
191
+ ## Related pages
192
+
193
+ - [Authorization](./authorization.md): what a branch capability grants and how
194
+ it is checked.
195
+ - [API reference](../api.md): every branch export, with its bounds and defaults.