@snagset/server 0.1.16 → 0.1.18

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 (193) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +9 -10
  3. package/dist/app.js +51 -47
  4. package/dist/app.js.map +1 -1
  5. package/dist/bin.js +31 -32
  6. package/dist/bin.js.map +1 -1
  7. package/dist/boot.js +21 -21
  8. package/dist/boot.js.map +1 -1
  9. package/dist/config.generated.js +5 -8
  10. package/dist/config.generated.js.map +1 -1
  11. package/dist/config.js +14 -14
  12. package/dist/config.js.map +1 -1
  13. package/dist/crypto.js +1 -1
  14. package/dist/crypto.js.map +1 -1
  15. package/dist/db/connect.js +1 -1
  16. package/dist/db/migrate.js +11 -11
  17. package/dist/db/rls.js +3 -3
  18. package/dist/db/rls.js.map +1 -1
  19. package/dist/domain.js +73 -5
  20. package/dist/domain.js.map +1 -1
  21. package/dist/explain.js +1 -1
  22. package/dist/instance/address.js +3 -3
  23. package/dist/instance/address.js.map +1 -1
  24. package/dist/instance/settings.js +1 -1
  25. package/dist/integrations/github.js +3 -3
  26. package/dist/integrations/github.js.map +1 -1
  27. package/dist/integrations/inbound.js +3 -3
  28. package/dist/integrations/inbound.js.map +1 -1
  29. package/dist/integrations/linear.js +1 -1
  30. package/dist/integrations/linear.js.map +1 -1
  31. package/dist/integrations/outbox.js +4 -4
  32. package/dist/integrations/outbox.js.map +1 -1
  33. package/dist/integrations/slack.js +2 -2
  34. package/dist/integrations/slack.js.map +1 -1
  35. package/dist/jobs/index.js +3 -3
  36. package/dist/jobs/reaper.js +11 -11
  37. package/dist/jobs/reaper.js.map +1 -1
  38. package/dist/jobs/set.js +3 -3
  39. package/dist/jobs/set.js.map +1 -1
  40. package/dist/jobs/soon.js +3 -3
  41. package/dist/keys.js +3 -3
  42. package/dist/keys.js.map +1 -1
  43. package/dist/listen.js +3 -3
  44. package/dist/listen.js.map +1 -1
  45. package/dist/mail/assigned.js +10 -10
  46. package/dist/mail/assigned.js.map +1 -1
  47. package/dist/mail/digest.js +3 -3
  48. package/dist/mail/digest.js.map +1 -1
  49. package/dist/mail/index.js +2 -2
  50. package/dist/mail/links.js +5 -5
  51. package/dist/mail/mailer.js +2 -2
  52. package/dist/mail/notify.js +22 -22
  53. package/dist/mail/notify.js.map +1 -1
  54. package/dist/mail/resend.js +1 -1
  55. package/dist/mail/resolve.js +1 -1
  56. package/dist/mail/resolve.js.map +1 -1
  57. package/dist/mail/smtp.js +1 -1
  58. package/dist/mail/unsubscribe.js +1 -1
  59. package/dist/net/publicFetch.js +1 -1
  60. package/dist/orgs.js +5 -5
  61. package/dist/orgs.js.map +1 -1
  62. package/dist/plan.js +1 -1
  63. package/dist/projects.js +4 -4
  64. package/dist/projects.js.map +1 -1
  65. package/dist/routes/account.js +1 -1
  66. package/dist/routes/account.js.map +1 -1
  67. package/dist/routes/activity.js +1 -1
  68. package/dist/routes/anchorReports.js +9 -9
  69. package/dist/routes/anchorReports.js.map +1 -1
  70. package/dist/routes/assignees.js +4 -4
  71. package/dist/routes/assignees.js.map +1 -1
  72. package/dist/routes/attachments.js +6 -6
  73. package/dist/routes/attachments.js.map +1 -1
  74. package/dist/routes/auth.js +4 -4
  75. package/dist/routes/bodyLimit.js +5 -5
  76. package/dist/routes/bodyLimit.js.map +1 -1
  77. package/dist/routes/commentEdit.js +5 -5
  78. package/dist/routes/commentEdit.js.map +1 -1
  79. package/dist/routes/comments.js +24 -24
  80. package/dist/routes/comments.js.map +1 -1
  81. package/dist/routes/cors.js +33 -15
  82. package/dist/routes/cors.js.map +1 -1
  83. package/dist/routes/instance.js +3 -3
  84. package/dist/routes/instance.js.map +1 -1
  85. package/dist/routes/integrations.js +14 -11
  86. package/dist/routes/integrations.js.map +1 -1
  87. package/dist/routes/invites.js +7 -7
  88. package/dist/routes/invites.js.map +1 -1
  89. package/dist/routes/mcpAuth.js +13 -13
  90. package/dist/routes/mcpAuth.js.map +1 -1
  91. package/dist/routes/mcpToken.js +4 -4
  92. package/dist/routes/mcpToken.js.map +1 -1
  93. package/dist/routes/mcpTokens.js +27 -14
  94. package/dist/routes/mcpTokens.js.map +1 -1
  95. package/dist/routes/members.js +9 -5
  96. package/dist/routes/members.js.map +1 -1
  97. package/dist/routes/owner.js +13 -13
  98. package/dist/routes/owner.js.map +1 -1
  99. package/dist/routes/reviewLinks.js +4 -4
  100. package/dist/routes/reviewLinks.js.map +1 -1
  101. package/dist/routes/rounds.js +3 -3
  102. package/dist/routes/rounds.js.map +1 -1
  103. package/dist/routes/sessions.js +186 -71
  104. package/dist/routes/sessions.js.map +1 -1
  105. package/dist/routes/sites.js +9 -9
  106. package/dist/routes/sites.js.map +1 -1
  107. package/dist/routes/threads.js +95 -95
  108. package/dist/routes/threads.js.map +1 -1
  109. package/dist/routes/widget.js +5 -5
  110. package/dist/routes/widget.js.map +1 -1
  111. package/dist/serverless.js +4 -4
  112. package/dist/serverless.js.map +1 -1
  113. package/dist/session.js +8 -8
  114. package/dist/session.js.map +1 -1
  115. package/dist/storage/bucket.js +4 -4
  116. package/dist/storage/bucket.js.map +1 -1
  117. package/dist/storage/settings.js +4 -4
  118. package/dist/storage/settings.js.map +1 -1
  119. package/dist/studioReviewers.js +4 -4
  120. package/dist/vendor/adapters/outbound/fence.js +2 -2
  121. package/dist/vendor/adapters/outbound/sanitise.js +14 -14
  122. package/dist/vendor/mcp/bin.js +4 -4
  123. package/dist/vendor/mcp/client.js +3 -3
  124. package/dist/vendor/mcp/server.js +3 -3
  125. package/dist/vendor/mcp/source.js +13 -13
  126. package/dist/vendor/mcp/view.js +15 -15
  127. package/dist/vendor/types/brief.js +2 -2
  128. package/dist/vendor/types/index.js +1 -1
  129. package/dist/vendor/types/names.js +20 -18
  130. package/dist/vendor/widget/object.js +3 -3
  131. package/dist/vendor/widget/predicates.js +38 -39
  132. package/package.json +1 -5
  133. package/static/dashboard/assets/{CompositeItem-DP7FLsIz.js → CompositeItem-D1SCvKo_.js} +1 -1
  134. package/static/dashboard/assets/{ConfirmDialog-B-mNmk6U.js → ConfirmDialog-DdUOWt96.js} +1 -1
  135. package/static/dashboard/assets/{CopyButton-bsaLPvqU.js → CopyButton-DX7vax3M.js} +1 -1
  136. package/static/dashboard/assets/{FieldItemContext-Cz9anwiZ.js → FieldItemContext-CMpCHNX8.js} +1 -1
  137. package/static/dashboard/assets/{InstallTag-DDMHFPFi.js → InstallTag-DkYPsEI0.js} +1 -1
  138. package/static/dashboard/assets/{SaveState-rgq6rRHH.js → SaveState-D0Hl8cny.js} +1 -1
  139. package/static/dashboard/assets/{_app-C5aeP_jI.js → _app-CcUZ9WT2.js} +1 -1
  140. package/static/dashboard/assets/{_app-B--Q2p1D.js → _app-CnG-wKIX.js} +1 -1
  141. package/static/dashboard/assets/{activity-CmpMwfWp.js → activity-DnvsedIb.js} +1 -1
  142. package/static/dashboard/assets/{arrow-left-BN3lUG8m.js → arrow-left-4jbTF1bA.js} +1 -1
  143. package/static/dashboard/assets/{avatar-4OkpxHzf.js → avatar-BKSc7tnv.js} +1 -1
  144. package/static/dashboard/assets/{badge-BGUQxxes.js → badge-aWVtPffe.js} +1 -1
  145. package/static/dashboard/assets/{card-BX32udhB.js → card-Bz0VdsxC.js} +1 -1
  146. package/static/dashboard/assets/{check-DjkY9Lhe.js → check-Ti2IOlgc.js} +1 -1
  147. package/static/dashboard/assets/{chevron-right-w26JMtvi.js → chevron-right-g-zbIopD.js} +1 -1
  148. package/static/dashboard/assets/{copy-BtOpNPn3.js → copy-BwBuD0FO.js} +1 -1
  149. package/static/dashboard/assets/{dialog-Ca351GqX.js → dialog-DdpHZobU.js} +1 -1
  150. package/static/dashboard/assets/{dropdown-menu-BdM8D0_i.js → dropdown-menu-CB2L3nig.js} +1 -1
  151. package/static/dashboard/assets/{image-off-DBc5pfc8.js → image-off-w_ej9pIJ.js} +1 -1
  152. package/static/dashboard/assets/{index-Bvmz5Xph.js → index-DtF8YHqn.js} +3 -3
  153. package/static/dashboard/assets/{invite-b1vVPR4n.js → invite-Dy3iQjzf.js} +1 -1
  154. package/static/dashboard/assets/{label-CeHVAC98.js → label-Dj8L94iS.js} +1 -1
  155. package/static/dashboard/assets/{layout-grid-BJUJgcuD.js → layout-grid-DuweH0Vl.js} +1 -1
  156. package/static/dashboard/assets/me-7-r9OeMR.js +1 -0
  157. package/static/dashboard/assets/{parts-BU_5BLga.js → parts-CA4cbfy3.js} +1 -1
  158. package/static/dashboard/assets/{plus-DW3meZh8.js → plus-CEWgXDLj.js} +1 -1
  159. package/static/dashboard/assets/s._site-D69M50OI.js +1 -0
  160. package/static/dashboard/assets/{s._site.comments-CyIqVrl3.js → s._site.comments-DNJ2SE5c.js} +1 -1
  161. package/static/dashboard/assets/{s._site.reviewers-muaVzTtT.js → s._site.reviewers-B07NzSWj.js} +1 -1
  162. package/static/dashboard/assets/{s._site.settings-Bow6VdXW.js → s._site.settings-B577G5iG.js} +1 -1
  163. package/static/dashboard/assets/{setup-B5fdtoM-.js → setup-DcjjtB3g.js} +1 -1
  164. package/static/dashboard/assets/{sign-in-D9Bhw619.js → sign-in-gfvOs6Jw.js} +1 -1
  165. package/static/dashboard/assets/{sign-up-CK-LWLzn.js → sign-up-CFJt7ibH.js} +1 -1
  166. package/static/dashboard/assets/{sites-CLB6RzU6.js → sites-DbB5cjz5.js} +1 -1
  167. package/static/dashboard/assets/studio-BO-MHFqc.js +1 -0
  168. package/static/dashboard/assets/{studio-CVFUbJQb.js → studio-D9GxJaSF.js} +1 -1
  169. package/static/dashboard/assets/{studio.index-CUtMjh5_.js → studio.index-DktAxIPT.js} +1 -1
  170. package/static/dashboard/assets/studio.integrations-CbLyqPLX.js +1 -0
  171. package/static/dashboard/assets/{studio.members-BcVrAT7r.js → studio.members-DFtIL3JS.js} +1 -1
  172. package/static/dashboard/assets/{sun-BzCBfLxA.js → sun-Bg7U-6Y0.js} +1 -1
  173. package/static/dashboard/assets/{switch-Djsy8JA6.js → switch-CTN6hIca.js} +1 -1
  174. package/static/dashboard/assets/{table-Cnpowiv-.js → table-zQ3pj50_.js} +1 -1
  175. package/static/dashboard/assets/{useAriaLabelledBy-D35XdVIh.js → useAriaLabelledBy-BsNncGbx.js} +1 -1
  176. package/static/dashboard/assets/{useBaseQuery-DztMyAqa.js → useBaseQuery-DSqwWD_O.js} +1 -1
  177. package/static/dashboard/assets/useQuery-OgrKuHbc.js +1 -0
  178. package/static/dashboard/assets/useRender-DOFVvGfR.js +1 -0
  179. package/static/dashboard/assets/{useValueChanged-DY7n1sYE.js → useValueChanged-B9k7l-mp.js} +1 -1
  180. package/static/dashboard/assets/{users-CR2o4PsB.js → users-CRiOyD0f.js} +1 -1
  181. package/static/dashboard/assets/{welcome-DJ_8xHTE.js → welcome-DT2DB4jN.js} +1 -1
  182. package/static/dashboard/assets/{widget-signin-BOulktuR.js → widget-signin-CC9owVvs.js} +1 -1
  183. package/static/dashboard/assets/{you-DJGezimL.js → you-CtcXklc_.js} +1 -1
  184. package/static/dashboard/assets/{zod-4RphBaVj.js → zod-CLxHj89d.js} +1 -1
  185. package/static/dashboard/index.html +1 -1
  186. package/static/widget/snag.capture.js +1 -1
  187. package/static/widget/snag.runtime.js +2 -2
  188. package/static/dashboard/assets/me-zNn3-t75.js +0 -1
  189. package/static/dashboard/assets/s._site-DP3hLlXp.js +0 -1
  190. package/static/dashboard/assets/studio-CuiXjnSt.js +0 -1
  191. package/static/dashboard/assets/studio.integrations-CiQ83f7Q.js +0 -1
  192. package/static/dashboard/assets/useQuery-DCEPkmeo.js +0 -1
  193. package/static/dashboard/assets/useRender-C2NyqaXd.js +0 -1
@@ -13,13 +13,13 @@ import { queueIntegrationEvent } from "../integrations/outbox.js";
13
13
  import { inOrg } from "../db/scope.js";
14
14
  import { isStudioModerator } from "../studioReviewers.js";
15
15
  /**
16
- * Wire id ⇄ row id — 05 §3.5, verbatim: *"id uuid PRIMARY KEY — client-minted
16
+ * Wire id ⇄ row id, verbatim: *"id uuid PRIMARY KEY — client-minted
17
17
  * UUIDv7; wire form 'thr_…'"*.
18
18
  *
19
19
  * The row is a bare uuid so it indexes and joins as one; the prefixed form is
20
20
  * the identity everything OUTSIDE the database uses, because a bare uuid in a
21
21
  * webhook payload, a GitHub issue marker or a support ticket tells you nothing
22
- * about what you are looking at (foundations §2.10).
22
+ * about what you are looking at.
23
23
  *
24
24
  * Both directions live here rather than at each call site. The widget mints
25
25
  * the id, so the very first POST arrives prefixed, and a server that stores
@@ -34,10 +34,10 @@ function rowId(wire, prefix) {
34
34
  }
35
35
  const wireId_ = (row, prefix) => prefix + row;
36
36
  /**
37
- * §2.2's envelope. `details` was missing from this file's copy while
37
+ * The envelope. `details` was missing from this file's copy while
38
38
  * `comments.ts` had it, so every `field_not_permitted` here — including the
39
39
  * long-standing refusal to move a thread between rounds — shipped without the
40
- * `details.field` §6.9 registers for that code. A client cannot mark the
40
+ * `details.field` registers for that code. A client cannot mark the
41
41
  * offending input without it.
42
42
  */
43
43
  function fail(c, status, code, message, details) {
@@ -45,8 +45,8 @@ function fail(c, status, code, message, details) {
45
45
  }
46
46
  /**
47
47
  * Every request runs inside a transaction with `app.org_id` set, which is what
48
- * makes 08 §9.2's RLS policies do anything at all. It is set even where the
49
- * connecting role can bypass them (D50) — the day someone points DATABASE_URL
48
+ * makes the RLS policies do anything at all. It is set even where the
49
+ * connecting role can bypass them — the day someone points DATABASE_URL
50
50
  * at a properly restricted role, every query here has to already be correct,
51
51
  * and a `SET LOCAL` added later would be added under incident conditions.
52
52
  */
@@ -58,12 +58,12 @@ function fail(c, status, code, message, details) {
58
58
  * added to each by hand after a link session's list was caught carrying a
59
59
  * private reply: a rule answered in three places drifts.
60
60
  *
61
- * ── TEAM-ONLY (05 §6.4a) ──
61
+ * ── TEAM-ONLY ──
62
62
  *
63
63
  * A `private` reply is for the studio's eyes and never reaches a reviewer
64
64
  * session or an agent. The owner sees all of them.
65
65
  *
66
- * ── TOMBSTONES, FOR THE OWNER ONLY (§3.11) ──
66
+ * ── TOMBSTONES, FOR THE OWNER ONLY ──
67
67
  *
68
68
  * A deleted comment is a row with `body` NULL. Everyone else gets it filtered
69
69
  * out, as before — the widget drops it and has nothing to render a hole with.
@@ -74,10 +74,10 @@ function fail(c, status, code, message, details) {
74
74
  * on the studio's screen as a tombstone exactly until the next poll replaced
75
75
  * the row, and then gone.
76
76
  *
77
- * The tombstone is §3.11's shape — id, NULL body, deletedAt, the author's
78
- * NAME only — plus `createdAt`, which §3.11 leaves out and the studio needs:
77
+ * The tombstone is the shape — id, NULL body, deletedAt, the author's
78
+ * NAME only — plus `createdAt`, which leaves out and the studio needs:
79
79
  * a hole is placed in time by when it was written. `seq` stays server-side
80
- * for §3.11's reason, and the author's id and kind go with the words.
80
+ * for the reason, and the author's id and kind go with the words.
81
81
  */
82
82
  function commentsJson(tx, asOwner) {
83
83
  return tx `COALESCE(
@@ -97,7 +97,7 @@ function commentsJson(tx, asOwner) {
97
97
  '[]'::json)`;
98
98
  }
99
99
  export function threadRoutes(sql,
100
- /** The 10-second cache (05 §4.4), not a fresh SELECT per handler. Every
100
+ /** The 10-second cache, not a fresh SELECT per handler. Every
101
101
  * route here starts by resolving `{uid}`, and on a database one round trip
102
102
  * away that lookup was costing more than the work it introduced. */
103
103
  projects,
@@ -106,8 +106,8 @@ projects,
106
106
  * not need to know how storage is configured. */
107
107
  features,
108
108
  /**
109
- * Is a mail transport configured (08 §4.8)? Same gate `commentRoutes` takes,
110
- * and for the same reason: with no transport, 08 §4.8 wants notifications
109
+ * Is a mail transport configured ? Same gate `commentRoutes` takes,
110
+ * and for the same reason: with no transport, the design wants notifications
111
111
  * "off and say so, rather than silently queueing" — rows that would otherwise
112
112
  * accumulate for the life of the install and arrive in one burst the day
113
113
  * somebody configures Resend.
@@ -116,12 +116,12 @@ notifications = async () => false,
116
116
  /** Where this instance lives (SNAGSET_BASE_URL) — the brief's screenshot links are absolute. */
117
117
  baseUrl = async () => "http://localhost:8080",
118
118
  /**
119
- * D95/D97: the site's tracker connections — id, provider, and whether
119
+ * The site's tracker connections — id, provider, and whether
120
120
  * issues are made on every new thread. Injected so this file never opens
121
121
  * a token; the worker does that.
122
122
  */
123
123
  trackers = async () => [],
124
- /** D123: how long an assignment waits before its email (SNAGSET_ASSIGN_MAIL_DELAY_S). */
124
+ /** How long an assignment waits before its email (SNAGSET_ASSIGN_MAIL_DELAY_S). */
125
125
  assignDelayMs) {
126
126
  const app = new Hono();
127
127
  /** Resolve `{uid}`, and 404 rather than leak whether a uid exists. */
@@ -132,7 +132,7 @@ assignDelayMs) {
132
132
  // request and, worse, ran AFTER the real one and overwrote
133
133
  // `Access-Control-Allow-Headers` with a hand-typed `content-type` — harmless
134
134
  // only because a preflight never reaches this far.
135
- /** 05 §6.1 — what the widget reads at mount to know what this deployment can do. */
135
+ /** What the widget reads at mount to know what this deployment can do. */
136
136
  app.get("/api/v1/projects/:uid/config", async (c) => {
137
137
  const p = await project(c.req.param("uid"));
138
138
  if (!p)
@@ -140,7 +140,7 @@ assignDelayMs) {
140
140
  c.header("x-snagset-config-version", String(p.config_version));
141
141
  c.header("ETag", `"${p.config_etag}"`);
142
142
  /**
143
- * CACHEABLE, briefly — D66 makes this the stub's visibility check, fetched
143
+ * CACHEABLE, briefly — the decision makes this the stub's visibility check, fetched
144
144
  * once per visitor per page-load-cluster rather than per page. 60 seconds:
145
145
  * long enough that a browsing session costs one request, short enough that
146
146
  * flipping the audience in the dashboard lands within a minute.
@@ -151,7 +151,7 @@ assignDelayMs) {
151
151
  // full body to a stranger origin, and the split below would be theatre.
152
152
  c.header("Vary", "Origin");
153
153
  /**
154
- * 05 §7.2's ORIGIN-AWARE BODY, finally conformed to (D69) — Eurico's
154
+ * The ORIGIN-AWARE BODY, finally conformed to — the maintainer's
155
155
  * question, verbatim: "the api gets the url that should be there and then
156
156
  * just validates in the frontend?" Which is this design: the server never
157
157
  * learns the page's URL (the browser sends only its origin, which CORS
@@ -161,14 +161,14 @@ assignDelayMs) {
161
161
  * for it is printed in their page source.
162
162
  *
163
163
  * A stranger origin still gets a 200 with the public subset (writeMode
164
- * included) rather than a 403: the fetch is also how D2's origin
164
+ * included) rather than a 403: the fetch is also how the origin
165
165
  * suggestion is born, and the stub on an archive.org mirror needs a
166
166
  * readable "writable: false" to stay dormant — a CORS-omitted header would
167
167
  * do that too, but this route is public-read so the suggestion flow works.
168
168
  * `originAllowed` is absent (never false) when there is no Origin header:
169
169
  * same-origin pages and curl are not strangers.
170
170
  */
171
- // D91 — the switch, 05 §4.9 layer 6: a paused site is writable from
171
+ // The switch, layer 6: a paused site is writable from
172
172
  // nowhere, and the stub reads `writable` and stays dormant. Said before
173
173
  // the origin verdict, because "switched off" is the more useful sentence.
174
174
  const paused = p.status !== "active";
@@ -182,9 +182,9 @@ assignDelayMs) {
182
182
  // mode is a complete product, not a degraded one.
183
183
  features: await features(p.org_id),
184
184
  /**
185
- * EFFECTIVE, not the raw column — D66 turns this field into the thing
185
+ * EFFECTIVE, not the raw column — turns this field into the thing
186
186
  * that decides whether every visitor sees the toolbar, and the raw value
187
- * lies during D1's grace: a site 25 hours old still says `open` while
187
+ * lies during the grace: a site 25 hours old still says `open` while
188
188
  * every write is already refused. The session route learned this first
189
189
  * (see effectiveWriteMode's own comment); the config route repeating the
190
190
  * raw column would show a toolbar nobody can type into.
@@ -194,9 +194,9 @@ assignDelayMs) {
194
194
  };
195
195
  if (!writable)
196
196
  return c.json({ ...base, reason: paused ? "project_paused" : "origin_not_allowed" });
197
- // T10/M2-22: the round HISTORY rides the config the widget already fetches
197
+ // T10/: the round HISTORY rides the config the widget already fetches
198
198
  // at mount, so the dock's selector costs no extra request. Behind the
199
- // origin split with `keyPrefix` (§7.2): the least sensitive PRIVATE things
199
+ // origin split with `keyPrefix`: the least sensitive PRIVATE things
200
200
  // on the project, which is not the same as public.
201
201
  const rounds = await inOrg(sql, p.org_id, (tx) => tx `
202
202
  SELECT number, name, closed_at IS NOT NULL AS closed
@@ -210,7 +210,7 @@ assignDelayMs) {
210
210
  });
211
211
  });
212
212
  /**
213
- * 05 §6.4 — the list, endpoint 7, which the route table marks **W/D**.
213
+ * The list, endpoint 7, which the route table marks **W/D**.
214
214
  *
215
215
  * ONE endpoint, TWO audiences, and the credential decides the scope. That is
216
216
  * the spec's design and not a shortcut: a dashboard is not a different kind
@@ -227,7 +227,7 @@ assignDelayMs) {
227
227
  * cross-origin credentialed read is refused by the browser whatever this
228
228
  * returns.
229
229
  *
230
- * `route` is the generated projection off the anchor (§3.5), so filtering by
230
+ * `route` is the generated projection off the anchor, so filtering by
231
231
  * it is an index scan rather than a JSONB walk on every row.
232
232
  */
233
233
  app.get("/api/v1/projects/:uid/threads", async (c) => {
@@ -241,7 +241,7 @@ assignDelayMs) {
241
241
  const owner = c.get("owner");
242
242
  const asOwner = !!owner && owner.orgId === p.org_id;
243
243
  /**
244
- * An MCP token with `threads:read` (07 §8.2) reads with the studio's
244
+ * An MCP token with `threads:read` reads with the studio's
245
245
  * FILTERS but not the studio's PRIVACY, and the split is deliberate.
246
246
  *
247
247
  * Filters: an agent asked to "list the open snags on checkout" needs
@@ -249,10 +249,10 @@ assignDelayMs) {
249
249
  * Denying those would make `list_threads` return the latest open round of
250
250
  * the default environment and call it the project.
251
251
  *
252
- * Privacy: `private` threads and team-only replies (§6.4a) stay OUT. 07's
252
+ * Privacy: `private` threads and team-only replies stay OUT. The
253
253
  * scope table grants "list and read threads, comments, anchor state" and
254
- * says nothing about private, while §8.6 mitigation 7 is *scope
255
- * minimalism* and §8.2 separates `context:read` precisely because the
254
+ * says nothing about private, while the design is *scope
255
+ * minimalism* and separates `context:read` precisely because the
256
256
  * personal-data-bearing half should not ride along by default. A team-only
257
257
  * note is the studio talking about the client; an agent that reads one and
258
258
  * quotes it into a pull request has published it.
@@ -268,8 +268,8 @@ assignDelayMs) {
268
268
  // Better a sentence naming the missing scope than a confident short list.
269
269
  if (mcp && !asAgent)
270
270
  return scopeDenied(c, "threads:read");
271
- // §6.4's filters. `status` and `q` stay dashboard-only. `round` used to be
272
- // gated the same way (`asOwner ? … : null`), which is the M2-22 bug: a
271
+ // The filters. `status` and `q` stay dashboard-only. `round` used to be
272
+ // gated the same way (`asOwner ? …: null`), which is the bug: a
273
273
  // review-link session's `?round` was silently discarded and every reader
274
274
  // without an owner cookie saw every round's pins forever, including ones
275
275
  // long since closed. A reviewer session may now ask for a round exactly
@@ -279,7 +279,7 @@ assignDelayMs) {
279
279
  const requestedRound = Number(c.req.query("round")) || null;
280
280
  const q = asStudio ? (c.req.query("q") ?? "").trim() : "";
281
281
  /**
282
- * `?assignee=rev_…|unassigned` — 05 §6.4b's triage filter ("what has
282
+ * `?assignee=rev_…|unassigned` — the triage filter ("what has
283
283
  * nobody picked up"). `unassigned` is a word rather than an empty value
284
284
  * because `assignee=` with nothing after it is indistinguishable from an
285
285
  * absent parameter on the wire. Studio-only, like status and q: a filter
@@ -294,7 +294,7 @@ assignDelayMs) {
294
294
  }
295
295
  }
296
296
  /**
297
- * `?since=` — 05 §6.3's delta poll, and A10's whole realtime story.
297
+ * `?since=` — the delta poll, and the whole realtime story.
298
298
  *
299
299
  * *"`GET …/threads?since=<iso>` returns everything with `updated_at >
300
300
  * since`, plus the `deleted[]` tombstone array. The client keeps
@@ -306,14 +306,14 @@ assignDelayMs) {
306
306
  * forever in a background tab.
307
307
  */
308
308
  /**
309
- * `?key=SN-14` — 05 §6.3's server-side permalink, and the thing that makes
309
+ * `?key=SN-14` — the server-side permalink, and the thing that makes
310
310
  * a shared link survive a round.
311
311
  *
312
312
  * `?snag_t=SN-14` resolves LOCALLY in the widget, against the list it
313
313
  * already holds. That list is round-scoped: a non-owner reader defaults to
314
314
  * the latest OPEN round. So a permalink to a thread from a closed round —
315
315
  * which is most permalinks anyone bothers to share, since they share the
316
- * ones that mattered — found nothing and showed F-06's "we could not find
316
+ * ones that mattered — found nothing and showed F-the "we could not find
317
317
  * that comment". The thread was there the whole time, one round back.
318
318
  *
319
319
  * An exact match on `UNIQUE (project_id, key)`, so at most one row, and it
@@ -321,7 +321,7 @@ assignDelayMs) {
321
321
  * named a specific thread, which is a stronger statement of intent than
322
322
  * any default. Privacy is not ignored — a private thread is absent for a
323
323
  * non-owner, which reads as "no such key" rather than confirming it exists
324
- * (§6.4a's rule for `GET /threads/{id}`).
324
+ * (the rule for `GET /threads/{id}`).
325
325
  */
326
326
  const key = c.req.query("key")?.trim().toUpperCase();
327
327
  const sinceRaw = c.req.query("since");
@@ -336,7 +336,7 @@ assignDelayMs) {
336
336
  /**
337
337
  * ── WHY `visible` IS A COLUMN AND NOT A FILTER ──
338
338
  *
339
- * 05 §6.4a, acceptance A36: a thread the studio marks `private` must land
339
+ * Acceptance A36: a thread the studio marks `private` must land
340
340
  * in the caller's `deleted[]` so a cached copy is DROPPED. With the
341
341
  * predicate in the WHERE clause the row simply stops being returned, which
342
342
  * is indistinguishable from "nothing changed" — the client keeps showing a
@@ -345,7 +345,7 @@ assignDelayMs) {
345
345
  *
346
346
  * Only the two genuine VISIBILITY conditions are computed. Status, search,
347
347
  * environment and round are FILTERS: status does not hide a thread, and a
348
- * thread can never change environment or round (D2 — `environment_id` is
348
+ * thread can never change environment or round (`environment_id` is
349
349
  * set at creation and a thread never moves), so none of them can take a
350
350
  * client from seeing a thread to not seeing it.
351
351
  *
@@ -392,7 +392,7 @@ assignDelayMs) {
392
392
  JOIN environments e ON e.id = t.environment_id
393
393
  -- Joined for the round filter, and the join is the fix for a real
394
394
  -- trap: threads.round_number is the thread's position WITHIN its round
395
- -- (01 §5.2 -- the number on the pin), while rounds.number is which
395
+ -- (-- the number on the pin), while rounds.number is which
396
396
  -- round it is. Two different integers whose names differ by one word,
397
397
  -- and filtering on the first is how round=1 returned the single thread
398
398
  -- that happened to be first in its round.
@@ -424,7 +424,7 @@ assignDelayMs) {
424
424
  : tx `AND t.assignee_id = ${assigneeFilter}::uuid`}
425
425
  -- Absent round query param: an OWNER still sees every round
426
426
  -- (unchanged), but a non-owner reader defaults to the latest OPEN
427
- -- round, so a fresh page load is D2-clean rather than showing a
427
+ -- round, so a fresh page load is -clean rather than showing a
428
428
  -- round that closed weeks ago. The ORDER BY number DESC LIMIT 1
429
429
  -- correlated subquery is cheap here -- one project's round table
430
430
  -- is a handful of rows, never a scan of threads.
@@ -479,11 +479,11 @@ assignDelayMs) {
479
479
  * clocks, one comparison: if the app server ran even a second ahead of the
480
480
  * database, every thread updated inside that second would be skipped on the
481
481
  * next poll and never returned again — a permanently missing comment, on a
482
- * skew nobody would think to look for. 05 §12.3 says the client must never
482
+ * skew nobody would think to look for. The design says the client must never
483
483
  * use its own clock; the same argument applies one layer down.
484
484
  */
485
485
  const [clock] = await sql `SELECT now() AS now`;
486
- // §6.3's tombstones. Ids only — the client already has these threads, and
486
+ // The tombstones. Ids only — the client already has these threads, and
487
487
  // a body would be handing back the private content that made it invisible.
488
488
  const visible = rows.filter((r) => r["visible"] !== false);
489
489
  const deleted = rows
@@ -497,7 +497,7 @@ assignDelayMs) {
497
497
  });
498
498
  });
499
499
  /**
500
- * 05 §6.2 — create. The whole thing is one transaction because a thread
500
+ * Create. The whole thing is one transaction because a thread
501
501
  * without its first comment is not a snag, it is a pin nobody can read.
502
502
  */
503
503
  app.post("/api/v1/projects/:uid/threads", async (c) => {
@@ -509,11 +509,11 @@ assignDelayMs) {
509
509
  return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
510
510
  }
511
511
  if (p.status !== "active") {
512
- // D91: the switch is off — writes refused, reads work (05 §4.9 layer 6).
512
+ // The switch is off — writes refused, reads work (layer 6).
513
513
  return fail(c, 403, "project_paused", "This site's widget is switched off. Its owner can switch it back on.");
514
514
  }
515
515
  if (!session.perm.includes("thread:create")) {
516
- // §4.8's matrix. A read-only session gets a sentence about permission,
516
+ // The matrix. A read-only session gets a sentence about permission,
517
517
  // not about authentication — they ARE signed in.
518
518
  return fail(c, 403, "forbidden", "This review link does not allow new comments.");
519
519
  }
@@ -535,8 +535,8 @@ assignDelayMs) {
535
535
  }
536
536
  const commentBody = typeof body.comment?.body === "string" && body.comment.body ? body.comment.body : null;
537
537
  const commentId = rowId(body.comment?.id, ID_PREFIX.comment) ?? randomUUID();
538
- // The attachments uploaded moments ago, to be BOUND by the write below (05
539
- // §6.6). Until that runs they are `pending` rows with no owner: §3.6's
538
+ // The attachments uploaded moments ago, to be BOUND by the write below
539
+ // Until that runs they are `pending` rows with no owner: The
540
540
  // CHECK forbids a bound attachment without exactly one of
541
541
  // thread_id/comment_id, which is what makes an orphan detectable rather
542
542
  // than merely unlikely. The bind is scoped to this project AND to rows
@@ -549,7 +549,7 @@ assignDelayMs) {
549
549
  //
550
550
  // This used to read `createdBy.holderSecret` off the request and mint a
551
551
  // reviewer from it — a second, weaker answer to "who is this?" sitting
552
- // beside the session that already answers it. 05 §4.4 is explicit that a
552
+ // beside the session that already answers it. The design is explicit that a
553
553
  // session's identity is decided at exchange and never taken from a request,
554
554
  // and leaving the body path in place would have meant a caller could author
555
555
  // as anyone by presenting a secret they had seen once.
@@ -566,7 +566,7 @@ assignDelayMs) {
566
566
  * work never was the cost. The *count* was.
567
567
  *
568
568
  * What survives is the ORDER, because the order is load-bearing —
569
- * §2.1's allocation still row-locks the project before the insert
569
+ * the allocation still row-locks the project before the insert
570
570
  * reads it, still serialises creation per project, and still yields a
571
571
  * gapless human id. A CTE does not relax that: data-modifying CTEs run
572
572
  * exactly once, to completion, and the insert that reads pseq/rseq
@@ -588,14 +588,14 @@ assignDelayMs) {
588
588
  `;
589
589
  // Kept as its own statement rather than folded into the write below,
590
590
  // because these two are the only failures a caller can act on and
591
- // §6.9 gives them distinct codes. A single statement that inserted
591
+ // the design gives them distinct codes. A single statement that inserted
592
592
  // nothing would have to report "something was missing".
593
593
  if (!ctx?.environment_id) {
594
594
  throw new HttpError(400, "environment_not_found", envKey
595
595
  ? `This project has no environment called "${envKey}".`
596
596
  : "This project has no default environment.");
597
597
  }
598
- // The active round: highest number, not closed. D2 — a thread belongs
598
+ // The active round: highest number, not closed. — a thread belongs
599
599
  // to whichever round was active when it was created, and never moves.
600
600
  if (!ctx.round_id)
601
601
  throw new HttpError(409, "no_active_round", "This project has no open round.");
@@ -642,7 +642,7 @@ assignDelayMs) {
642
642
  ),
643
643
  -- The author's row: seen now, and the counters the assignee pool
644
644
  -- reads (05 section 3.4, section 6.4b -- "everyone who has
645
- -- commented" is thread_count > 0 OR comment_count > 0). Until D90
645
+ -- commented" is thread_count > 0 OR comment_count > 0). Until
646
646
  -- nothing moved thread_count at all, so a client whose only act
647
647
  -- was leaving a pin was invisible to the pool. ONE update: two
648
648
  -- data-modifying CTEs touching the same row in one statement is
@@ -698,7 +698,7 @@ assignDelayMs) {
698
698
  pageUrl: body.context?.url ?? "",
699
699
  }, randomUUID());
700
700
  }
701
- // D95: a site whose tracker makes an issue on every new thread. Same
701
+ // A site whose tracker makes an issue on every new thread. Same
702
702
  // transaction as the thread, keyed on the thread, so a retried POST
703
703
  // cannot make a second issue.
704
704
  for (const link of await trackers(p.org_id, p.id)) {
@@ -723,7 +723,7 @@ assignDelayMs) {
723
723
  if (e instanceof HttpError)
724
724
  return fail(c, e.status, e.code, e.message);
725
725
  /**
726
- * A RETRY IS NOT AN ERROR — 05 §11's idempotency, by natural key.
726
+ * A RETRY IS NOT AN ERROR — the idempotency, by natural key.
727
727
  *
728
728
  * A double-tapped Send, or a reviewer on a train whose request landed
729
729
  * while their connection dropped before the response did, sends the same
@@ -734,7 +734,7 @@ assignDelayMs) {
734
734
  * connection is the moment they try again, or give up and stop using it.
735
735
  *
736
736
  * The id makes this decidable with no header and no client change: the
737
- * widget already mints it (§6.2), so a second POST carrying an id we
737
+ * widget already mints it, so a second POST carrying an id we
738
738
  * already hold IS the same request. Returning what the first one created
739
739
  * is the whole of the fix.
740
740
  *
@@ -790,7 +790,7 @@ assignDelayMs) {
790
790
  }
791
791
  });
792
792
  /**
793
- * 05 §6.4 — status changes, endpoint 9, marked **W/D** like the list.
793
+ * Status changes, endpoint 9, marked **W/D** like the list.
794
794
  *
795
795
  * IT SHIPPED WITH NO AUTHENTICATION CHECK AT ALL. Not a weak one — none: the
796
796
  * handler read the uid, read the body and wrote. `bearerAuth` was mounted on
@@ -801,15 +801,15 @@ assignDelayMs) {
801
801
  *
802
802
  * Two credentials are legitimate here and they are checked separately: an
803
803
  * OWNER of this project's org, and a reviewer session carrying
804
- * `thread:resolve` (§4.8's matrix — it is in the default link grant, because
805
- * a client marking their own snag done is the loop D4 is built around).
804
+ * `thread:resolve` (the matrix — it is in the default link grant, because
805
+ * a client marking their own snag done is the loop the decision is built around).
806
806
  */
807
807
  /**
808
- * 05 §6.4 endpoint 8 — one thread — and D93's brief: the same row as
808
+ * Endpoint 8 — one thread — and the brief: the same row as
809
809
  * `text/markdown` when the id ends in `.md`, for curl, an agent's shell or
810
810
  * a ticket body. The privacy rule is the list's: a private thread is absent
811
811
  * for a non-owner, which reads as "no such thread" rather than confirming
812
- * one exists (§6.4a), and team-only replies stay out of a client's copy.
812
+ * one exists, and team-only replies stay out of a client's copy.
813
813
  */
814
814
  app.get("/api/v1/projects/:uid/threads/:id", async (c) => {
815
815
  const p = await project(c.req.param("uid"));
@@ -880,10 +880,10 @@ assignDelayMs) {
880
880
  const mcp = c.get("mcp");
881
881
  const asOwner = !!owner && owner.orgId === p.org_id;
882
882
  /**
883
- * `mark_ready` (07 §8.3) lands here — and this is the whole of an agent's
883
+ * `mark_ready` lands here — and this is the whole of an agent's
884
884
  * write surface on a thread, `threads:write`.
885
885
  *
886
- * §8.6 mitigation 4: *"an agent can propose, never dispose"*. There is
886
+ * *"an agent can propose, never dispose"*. There is
887
887
  * deliberately no tool that resolves a thread, and the refusal below is
888
888
  * where that is actually true rather than merely absent from a tool list:
889
889
  * an agent PATCHing `status: 'resolved'` on this route is refused even
@@ -900,20 +900,20 @@ assignDelayMs) {
900
900
  return fail(c, 403, "forbidden", "This review link does not allow changing status.");
901
901
  }
902
902
  const body = (await c.req.json().catch(() => null));
903
- // D2: a thread belongs to exactly one round and NEVER moves. Refused
903
+ // A thread belongs to exactly one round and NEVER moves. Refused
904
904
  // explicitly rather than ignored, because a caller that sends `round` and
905
905
  // gets a 200 will believe it moved — and the whole model rests on the fact
906
- // that it did not. §6.9's `field_not_permitted` is the registered code.
906
+ // that it did not. The `field_not_permitted` is the registered code.
907
907
  for (const field of ["round", "roundId", "roundNumber"]) {
908
908
  if (body && field in body) {
909
909
  return fail(c, 422, "field_not_permitted", "A comment stays in the round it was left in. Rounds are periods of time, not folders.", { field });
910
910
  }
911
911
  }
912
912
  /**
913
- * `private` — 05 §6.4, and the write that makes `deleted[]` mean something.
913
+ * `private`, and the write that makes `deleted[]` mean something.
914
914
  *
915
915
  * Studio-only, like the thread-level field it is: an owner always may, a
916
- * reviewer session needs the link permission that names it. §6.4 is
916
+ * reviewer session needs the link permission that names it. The design is
917
917
  * explicit that this is `403 field_not_permitted` with `details.field`
918
918
  * rather than a silently ignored field, because a caller who sends
919
919
  * `private: true` and gets a 200 will believe the thread is private.
@@ -925,14 +925,14 @@ assignDelayMs) {
925
925
  });
926
926
  }
927
927
  if (wantsPrivate !== undefined && asAgent && !asOwner) {
928
- // §8.2: an agent write is never laundered into a trust tier it did not
928
+ // An agent write is never laundered into a trust tier it did not
929
929
  // earn, and team-only is the studio's register.
930
930
  return fail(c, 403, "field_not_permitted", "An agent cannot make a thread team-only.", {
931
931
  field: "private",
932
932
  });
933
933
  }
934
934
  /**
935
- * `assignee` — 05 §6.4b, D90. A reviewer id from the pool, or null to
935
+ * `assignee`. A reviewer id from the pool, or null to
936
936
  * un-assign. Same register as `private`: the studio's own routing of
937
937
  * work, so an owner always may, a reviewer session needs the permission
938
938
  * that names it, and a refusal is `403 field_not_permitted` with
@@ -954,7 +954,7 @@ assignDelayMs) {
954
954
  }
955
955
  }
956
956
  if (asAgent && !asOwner) {
957
- // 07 §8.6 mitigation 4 again: an agent proposes, never disposes —
957
+ // Again: an agent proposes, never disposes
958
958
  // and handing a thread to a person is disposing of it.
959
959
  return fail(c, 403, "field_not_permitted", "An agent cannot assign a comment to someone.", {
960
960
  field: "assignee",
@@ -976,7 +976,7 @@ assignDelayMs) {
976
976
  return fail(c, 400, "validation_failed", "status must be one of open, progress, review, resolved.");
977
977
  }
978
978
  /**
979
- * F-26's expected version — 01 §1221, AC-1-11's "no silent overwrite".
979
+ * F-26's expected version, the "no silent overwrite".
980
980
  *
981
981
  * `threads.version` has been incremented on every write since 0001 and
982
982
  * READ by nothing: the column existed, the guarantee did not. Two studio
@@ -987,7 +987,7 @@ assignDelayMs) {
987
987
  * OPTIONAL, deliberately. A caller that sends no `version` is saying "I am
988
988
  * not editing a copy I read earlier" — the CLI, an agent acting on a fresh
989
989
  * read, a curl. Making it mandatory would break every existing caller to
990
- * protect a case they are not in, and 05 §11.1's additive rule forbids
990
+ * protect a case they are not in, and the additive rule forbids
991
991
  * that. The dashboard and the widget send it because they ARE editing a
992
992
  * copy they read earlier, which is exactly the population F-26 is about.
993
993
  */
@@ -1004,7 +1004,7 @@ assignDelayMs) {
1004
1004
  return fail(c, 403, "forbidden", "An agent may mark a snag ready for review, but not resolve it. " +
1005
1005
  "Whoever reported it decides when it is fixed — tell your user it is ready.");
1006
1006
  }
1007
- // 07 §8.4's stop signal, spent before the write rather than after it.
1007
+ // The stop signal, spent before the write rather than after it.
1008
1008
  const spent = await checkWriteBudget(sql, mcp.tokenId);
1009
1009
  if (spent !== null)
1010
1010
  return budgetExhausted(c);
@@ -1030,10 +1030,10 @@ assignDelayMs) {
1030
1030
  let conflict;
1031
1031
  // Set by the transaction when the candidate assignee is not in the pool.
1032
1032
  let badAssignee = false;
1033
- // Read by the transaction when an assignee is being set (D123).
1033
+ // Read by the transaction when an assignee is being set.
1034
1034
  let previousAssignee = null;
1035
1035
  /**
1036
- * The person pressing the button, as D123's assignment mail needs them:
1036
+ * The person pressing the button, as the assignment mail needs them:
1037
1037
  * never mailed about handing a snag to themselves, and — when they ARE
1038
1038
  * the assignee and move its status — the mail about it is dropped,
1039
1039
  * because they have plainly seen it. An agent is nobody here: it cannot
@@ -1070,7 +1070,7 @@ assignDelayMs) {
1070
1070
  }
1071
1071
  }
1072
1072
  /**
1073
- * The candidate must be IN THE POOL — §6.4b's predicate, shared with
1073
+ * The candidate must be IN THE POOL — the predicate, shared with
1074
1074
  * the pool route — and in THIS project. Blocked and erased reviewers
1075
1075
  * keep the assignments they have but take no new ones; a guest who
1076
1076
  * never wrote is not assignable; a reviewer id from another project is
@@ -1087,7 +1087,7 @@ assignDelayMs) {
1087
1087
  }
1088
1088
  // Who held it BEFORE, under the row lock the UPDATE below would take
1089
1089
  // anyway: re-sending the assignee a thread already has is not a new
1090
- // assignment, and must not be a second mail (D123).
1090
+ // assignment, and must not be a second mail.
1091
1091
  const [was] = await tx `
1092
1092
  SELECT assignee_id FROM threads
1093
1093
  WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid
@@ -1096,7 +1096,7 @@ assignDelayMs) {
1096
1096
  previousAssignee = was?.assignee_id ?? null;
1097
1097
  }
1098
1098
  /**
1099
- * §6.4 consequence TWO of three, and it runs before the CTE rather than
1099
+ * Consequence TWO of three, and it runs before the CTE rather than
1100
1100
  * inside it.
1101
1101
  *
1102
1102
  * *"every comment on a thread becoming private is set `private = true` in
@@ -1185,7 +1185,7 @@ assignDelayMs) {
1185
1185
  `;
1186
1186
  /**
1187
1187
  * `thread.resolved` — B2's second topic, queued in the SAME transaction
1188
- * as the status change (07 §2.5), so a resolve that commits without its
1188
+ * as the status change, so a resolve that commits without its
1189
1189
  * notification is impossible and a notification about a resolve that
1190
1190
  * rolled back cannot exist.
1191
1191
  *
@@ -1213,10 +1213,10 @@ assignDelayMs) {
1213
1213
  });
1214
1214
  }
1215
1215
  /**
1216
- * D123 — "this snag is yours now", an hour from now and only if it is
1216
+ * "this snag is yours now", an hour from now and only if it is
1217
1217
  * still true then (`mail/assigned.ts`). Queued here, in the transaction
1218
1218
  * that assigned it, like every outbox row; only when it is a NEW holder
1219
- * and only when mail is configured at all (08 §4.8: off and say so,
1219
+ * and only when mail is configured at all (off and say so,
1220
1220
  * rather than rows that arrive in a burst the day somebody sets it up).
1221
1221
  */
1222
1222
  if (assigneeRow && assigneeRow !== previousAssignee && actor && updated?.[0] && (await notifications())) {
@@ -1240,7 +1240,7 @@ assignDelayMs) {
1240
1240
  if (status !== undefined && actor && updated?.[0]) {
1241
1241
  await dropAssignedBy(tx, threadRow, actor);
1242
1242
  }
1243
- // D95: our status is canonical, the tracker's follows — queued here, in
1243
+ // Our status is canonical, the tracker's follows — queued here, in
1244
1244
  // the transaction that moved it, keyed on the version so every change
1245
1245
  // is one row. The worker maps by state type: resolved closes as
1246
1246
  // completed, anything else reopens; a thread with no issue is skipped.
@@ -1261,7 +1261,7 @@ assignDelayMs) {
1261
1261
  });
1262
1262
  /**
1263
1263
  * F-26, on the wire. 409 and not 412: `If-Match` semantics would make this
1264
- * a header concern, and 01 §1221 describes a product event ("someone else
1264
+ * a header concern, and the design describes a product event ("someone else
1265
1265
  * updated this while you were typing") that the client renders with **Post
1266
1266
  * anyway** and **See what changed** — a conflict, not a failed
1267
1267
  * precondition. `currentVersion` rides along so the refetch has something
@@ -1279,7 +1279,7 @@ assignDelayMs) {
1279
1279
  if (!row)
1280
1280
  return fail(c, 404, "thread_not_found", "No such thread in this project.");
1281
1281
  /**
1282
- * 07 §8.6 mitigation 8: every agent write lands in `events` with the
1282
+ * Every agent write lands in `events` with the
1283
1283
  * reviewer id and the token id.
1284
1284
  *
1285
1285
  * A SECOND transaction, and this is the one place that is not ideal: the
@@ -1316,12 +1316,12 @@ assignDelayMs) {
1316
1316
  * 05 endpoint 10 — deleting a whole thread, project-scoped like the PATCH
1317
1317
  * above. The studio, any thread; a reviewer, their own.
1318
1318
  *
1319
- * §4.8's matrix gives a reviewer "own only", behind the link permission
1319
+ * The matrix gives a reviewer "own only", behind the link permission
1320
1320
  * `thread:delete:own` — on by default since 0.1.14 for guests and new
1321
1321
  * links, because the widget offered Delete on a reviewer's own snag and
1322
1322
  * this route refused it. "Own" is narrower than "opened": every live
1323
1323
  * comment in the thread must be theirs, so nobody's reply goes with it.
1324
- * An agent is still refused (§8.6 mitigation 4 — an agent proposes, never
1324
+ * An agent is still refused (an agent proposes, never
1325
1325
  * disposes, and deleting is the most disposing act there is).
1326
1326
  *
1327
1327
  * ── SOFT, LIKE A SITE ──
@@ -1333,7 +1333,7 @@ assignDelayMs) {
1333
1333
  * column, and an operator who deletes the wrong thread has a row to
1334
1334
  * restore. `version` and `updated_at` move with it so a `?since=` poll —
1335
1335
  * every other open dashboard, every reviewer's widget — gets the id in
1336
- * `deleted[]` and drops its copy (§6.3), rather than showing a thread that
1336
+ * `deleted[]` and drops its copy, rather than showing a thread that
1337
1337
  * is gone until somebody reloads.
1338
1338
  *
1339
1339
  * ── THE COMMENTS ARE LEFT ALONE, DELIBERATELY ──
@@ -1343,7 +1343,7 @@ assignDelayMs) {
1343
1343
  * thread's `deleted_at`; the two comment routes that address a comment by
1344
1344
  * its own id (commentEdit.ts) answer 404 once its thread is deleted. So
1345
1345
  * tombstoning them would hide nothing more today, and it would make the
1346
- * delete irreversible — §3.11's tombstone NULLs the body, and a restored
1346
+ * delete irreversible — the tombstone NULLs the body, and a restored
1347
1347
  * thread with every word gone is not a restore. The counters stay as they
1348
1348
  * are for the same reason.
1349
1349
  *
@@ -1361,7 +1361,7 @@ assignDelayMs) {
1361
1361
  * with and never looks back at the thread. A tracker row would be
1362
1362
  * dropped by its worker anyway ("the thread is gone"); dropping it here
1363
1363
  * says so in one place. Rows already in flight are left to finish.
1364
- * · Linked issues in GitHub, Linear, Jira or Slack are NOT touched. 07's
1364
+ * · Linked issues in GitHub, Linear, Jira or Slack are NOT touched. The
1365
1365
  * `thread.deleted` event is not built, and closing a developer's issue
1366
1366
  * because a comment was moderated is not obviously what anyone wants.
1367
1367
  * An inbound webhook for that issue finds no thread and is ignored.
@@ -1398,7 +1398,7 @@ assignDelayMs) {
1398
1398
  if (!session.perm.includes("thread:delete:own")) {
1399
1399
  return fail(c, 403, "forbidden", "Your review link does not allow deleting comments.");
1400
1400
  }
1401
- // A studio owner or admin signed in from the widget (D122) deletes
1401
+ // A studio owner or admin signed in from the widget deletes
1402
1402
  // any thread, as in the dashboard.
1403
1403
  const moderator = await inOrg(sql, p.org_id, (tx) => isStudioModerator(tx, p.org_id, session.sub));
1404
1404
  const [t] = moderator ? [{ all_mine: true, any: true }] : await inOrg(sql, p.org_id, (tx) => tx `
@@ -1420,7 +1420,7 @@ assignDelayMs) {
1420
1420
  }
1421
1421
  }
1422
1422
  else {
1423
- // §4.8's matrix: member, admin and owner may; a viewer may not. Nobody
1423
+ // The matrix: member, admin and owner may; a viewer may not. Nobody
1424
1424
  // can be GIVEN a viewer seat yet (members.ts wires owner and admin only),
1425
1425
  // so this is the rule written down where it applies, before the day the
1426
1426
  // seat exists and every route has to be audited for it.
@@ -1451,7 +1451,7 @@ assignDelayMs) {
1451
1451
  `;
1452
1452
  return !!was;
1453
1453
  }
1454
- // The thread's own pictures and any bound to its comments: FOLDBACK
1454
+ // The thread's own pictures and any bound to its comments:
1455
1455
  // section A6 hangs an attachment off exactly one of the two.
1456
1456
  await tx `
1457
1457
  UPDATE attachments SET deleted_at = now()
@@ -1491,7 +1491,7 @@ class HttpError extends Error {
1491
1491
  this.code = code;
1492
1492
  }
1493
1493
  }
1494
- /** Rows → 00 §5's `Thread`. The shape the widget already renders, unchanged. */
1494
+ /** Rows → the `Thread`. The shape the widget already renders, unchanged. */
1495
1495
  function shape(r) {
1496
1496
  return {
1497
1497
  id: wireId_(r.id, ID_PREFIX.thread),
@@ -1527,7 +1527,7 @@ function shape(r) {
1527
1527
  isTeam: !!r.assignee_is_team,
1528
1528
  }
1529
1529
  : null,
1530
- // D95: where this thread lives elsewhere — the GitHub issue, later the
1530
+ // Where this thread lives elsewhere — the GitHub issue, later the
1531
1531
  // Linear and Jira ones. Always an array, so a merge can clear it.
1532
1532
  refs: r.refs ?? [],
1533
1533
  createdAt: new Date(r.created_at).toISOString(),