@snagset/server 0.0.1

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 (105) hide show
  1. package/README.md +83 -0
  2. package/dist/app.js +453 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/bin.js +951 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/boot.js +321 -0
  7. package/dist/boot.js.map +1 -0
  8. package/dist/config.generated.js +116 -0
  9. package/dist/config.generated.js.map +1 -0
  10. package/dist/config.js +180 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/crypto.js +26 -0
  13. package/dist/crypto.js.map +1 -0
  14. package/dist/db/connect.js +49 -0
  15. package/dist/db/connect.js.map +1 -0
  16. package/dist/db/migrate.js +210 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/migrations/0001_init.sql +1060 -0
  19. package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
  20. package/dist/db/migrations/0003_email_provider.sql +35 -0
  21. package/dist/db/migrations/0004_unsubscribe.sql +24 -0
  22. package/dist/db/rls.js +58 -0
  23. package/dist/db/rls.js.map +1 -0
  24. package/dist/jobs/index.js +171 -0
  25. package/dist/jobs/index.js.map +1 -0
  26. package/dist/jobs/reaper.js +182 -0
  27. package/dist/jobs/reaper.js.map +1 -0
  28. package/dist/keys.js +77 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/mail/index.js +75 -0
  31. package/dist/mail/index.js.map +1 -0
  32. package/dist/mail/mailer.js +27 -0
  33. package/dist/mail/mailer.js.map +1 -0
  34. package/dist/mail/notify.js +405 -0
  35. package/dist/mail/notify.js.map +1 -0
  36. package/dist/mail/resend.js +95 -0
  37. package/dist/mail/resend.js.map +1 -0
  38. package/dist/mail/smtp.js +315 -0
  39. package/dist/mail/smtp.js.map +1 -0
  40. package/dist/mail/unsubscribe.js +134 -0
  41. package/dist/mail/unsubscribe.js.map +1 -0
  42. package/dist/projectCache.js +50 -0
  43. package/dist/projectCache.js.map +1 -0
  44. package/dist/projects.js +101 -0
  45. package/dist/projects.js.map +1 -0
  46. package/dist/routes/attachments.js +159 -0
  47. package/dist/routes/attachments.js.map +1 -0
  48. package/dist/routes/auth.js +55 -0
  49. package/dist/routes/auth.js.map +1 -0
  50. package/dist/routes/commentEdit.js +201 -0
  51. package/dist/routes/commentEdit.js.map +1 -0
  52. package/dist/routes/comments.js +313 -0
  53. package/dist/routes/comments.js.map +1 -0
  54. package/dist/routes/cors.js +174 -0
  55. package/dist/routes/cors.js.map +1 -0
  56. package/dist/routes/jobs.js +56 -0
  57. package/dist/routes/jobs.js.map +1 -0
  58. package/dist/routes/mcpAuth.js +187 -0
  59. package/dist/routes/mcpAuth.js.map +1 -0
  60. package/dist/routes/mcpToken.js +94 -0
  61. package/dist/routes/mcpToken.js.map +1 -0
  62. package/dist/routes/owner.js +288 -0
  63. package/dist/routes/owner.js.map +1 -0
  64. package/dist/routes/reviewLinks.js +169 -0
  65. package/dist/routes/reviewLinks.js.map +1 -0
  66. package/dist/routes/rounds.js +137 -0
  67. package/dist/routes/rounds.js.map +1 -0
  68. package/dist/routes/sessions.js +349 -0
  69. package/dist/routes/sessions.js.map +1 -0
  70. package/dist/routes/sites.js +192 -0
  71. package/dist/routes/sites.js.map +1 -0
  72. package/dist/routes/threads.js +1080 -0
  73. package/dist/routes/threads.js.map +1 -0
  74. package/dist/routes/widget.js +132 -0
  75. package/dist/routes/widget.js.map +1 -0
  76. package/dist/serverless.js +97 -0
  77. package/dist/serverless.js.map +1 -0
  78. package/dist/session.js +152 -0
  79. package/dist/session.js.map +1 -0
  80. package/dist/storage/bucket.js +116 -0
  81. package/dist/storage/bucket.js.map +1 -0
  82. package/dist/storage/settings.js +132 -0
  83. package/dist/storage/settings.js.map +1 -0
  84. package/dist/vendor/adapters/index.js +1 -0
  85. package/dist/vendor/adapters/outbound/fence.js +44 -0
  86. package/dist/vendor/adapters/outbound/sanitise.js +233 -0
  87. package/dist/vendor/types/index.js +4 -0
  88. package/dist/vendor/types/names.js +254 -0
  89. package/dist/vendor/widget/object.js +38 -0
  90. package/dist/vendor/widget/predicates.js +377 -0
  91. package/package.json +53 -0
  92. package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
  93. package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
  94. package/static/dashboard/index.html +14 -0
  95. package/static/widget/snag.anchor-ext.js +1 -0
  96. package/static/widget/snag.anchor-fuzzy.js +1 -0
  97. package/static/widget/snag.capture.js +4 -0
  98. package/static/widget/snag.crumbs.js +4 -0
  99. package/static/widget/snag.dock.js +5 -0
  100. package/static/widget/snag.js +5 -0
  101. package/static/widget/snag.panels.js +2 -0
  102. package/static/widget/snag.runtime.js +2 -0
  103. package/static/widget/snag.screenshot.js +1 -0
  104. package/static/widget/snag.text.js +2 -0
  105. package/vercel.json +6 -0
@@ -0,0 +1,1060 @@
1
+ -- ─────────────────────────────────────────────────────────────────────────
2
+ -- GENERATED FILE — DO NOT EDIT BY HAND.
3
+ -- Source of truth: specs/05-api-data.md §3 (the schema itself, not prose
4
+ -- about one). Regenerate: pnpm gen:migration (CI runs gen:migration:check)
5
+ --
6
+ -- Edit the SPEC to change the database. That is the whole point: 05 §3
7
+ -- carries the reasoning for every column beside the column, and a migration
8
+ -- transcribed by hand is a second source of truth that is wrong by the
9
+ -- second edit.
10
+ --
11
+ -- 32 tables · 15 enums · 58 indexes
12
+ --
13
+ -- 1 statement(s) in §3 are gated to a later release and are NOT here:
14
+ -- v2: CREATE INDEX threads_anchor_text_trgm ON threads
15
+ -- ─────────────────────────────────────────────────────────────────────────
16
+
17
+ -- 08 §2.3: zero extensions. UUIDs are minted by the CLIENT (UUIDv7, ordered
18
+ -- by time), so there is no pgcrypto, no uuid-ossp, and nothing to install
19
+ -- on a managed Postgres that may not permit installing anything.
20
+
21
+ CREATE TYPE snag_project_status AS ENUM ('active','paused','disabled','deleting');
22
+ CREATE TYPE snag_write_mode AS ENUM ('open','link-required','verified-only');
23
+ CREATE TYPE snag_thread_status AS ENUM ('open','progress','review','resolved');
24
+ CREATE TYPE snag_anchor_state AS ENUM ('anchored','approximate','orphaned');
25
+ CREATE TYPE snag_reviewer_kind AS ENUM ('guest','link','verified');
26
+ CREATE TYPE snag_attachment_kind AS ENUM ('screenshot','image','file','dom-snapshot'); -- 'file' added by D18
27
+ CREATE TYPE snag_attachment_state AS ENUM ('pending','bound','orphaned');
28
+ CREATE TYPE snag_moderation AS ENUM ('visible','held','spam');
29
+ CREATE TYPE snag_provider AS ENUM ('github','linear','slack','webhook');
30
+ CREATE TYPE snag_ref_kind AS ENUM ('issue','pr','message','attachment','delivery');
31
+ CREATE TYPE snag_ref_status AS ENUM ('synced','pending','failed','detached');
32
+ CREATE TYPE snag_member_role AS ENUM ('owner','admin','member','viewer');
33
+ CREATE TYPE snag_outbox_status AS ENUM ('pending','inflight','done','failed','dropped');
34
+ CREATE TYPE snag_sync_direction AS ENUM ('inbound','outbound');
35
+ CREATE TYPE snag_sync_entity AS ENUM ('thread','comment','status');
36
+
37
+ CREATE TABLE orgs (
38
+ id uuid PRIMARY KEY,
39
+ name text NOT NULL,
40
+ slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
41
+ plan text NOT NULL DEFAULT 'selfhost',
42
+ created_at timestamptz NOT NULL DEFAULT now(),
43
+ updated_at timestamptz NOT NULL DEFAULT now(),
44
+ -- ── The pixel tier's bucket (A9b, LOCK L2/L3, D18) ──────────────────────
45
+ --
46
+ -- INSTANCE-level, not per project: one bucket serves every project, and a
47
+ -- v1 self-host has exactly one org. `features: {screenshots, uploads}` in
48
+ -- the bootstrap config is derived from `storage_connected_at IS NOT NULL`
49
+ -- — server-side, because A9b is explicit that "the widget never probes
50
+ -- storage".
51
+ --
52
+ -- These columns exist because nothing else could hold them. Sweep R4
53
+ -- deleted every `SNAGSET_STORAGE_S3_*` variable on the grounds that "the
54
+ -- bucket is connected in the admin UI and stored encrypted under
55
+ -- credential-enc" — but no table had a column for it, and `integrations`
56
+ -- is per-project with a `provider` enum that has no storage member. So the
57
+ -- credentials had nowhere to live at all, on any deployment, which made
58
+ -- the pixel tier unreachable rather than merely un-built.
59
+ --
60
+ -- Endpoint, bucket, region, and the public base URL if one is configured.
61
+ -- Non-secret by construction; the keys are in the encrypted pair below.
62
+ storage_config jsonb NOT NULL DEFAULT '{}'::jsonb,
63
+ -- AES-256-GCM under HKDF(SNAGSET_SECRET_KEY, 'credential-enc') — the same
64
+ -- purpose and the same shape as `integrations.credentials`, deliberately,
65
+ -- so there is one way to hold a secret at rest rather than two.
66
+ storage_credentials bytea,
67
+ storage_credentials_iv bytea,
68
+ -- NULL = annotation mode. Disconnecting nulls this and leaves the
69
+ -- attachment rows' `storage_key`s intact (LOCK L2), so reconnecting the
70
+ -- same bucket restores the pictures rather than orphaning them.
71
+ storage_connected_at timestamptz,
72
+ CHECK ((storage_credentials IS NULL) = (storage_credentials_iv IS NULL)),
73
+ CHECK (storage_connected_at IS NULL OR storage_credentials IS NOT NULL),
74
+ deleted_at timestamptz
75
+ );
76
+
77
+ CREATE TABLE users ( -- dashboard/owner identity. NOT reviewers.
78
+ id uuid PRIMARY KEY,
79
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, -- HOME org (§3.0)
80
+ email text NOT NULL,
81
+ email_verified boolean NOT NULL DEFAULT false,
82
+ name text NOT NULL DEFAULT '',
83
+ avatar_url text,
84
+ password_hash text, -- argon2id; NULL when OAuth-only
85
+ totp_secret text, -- v2
86
+ created_at timestamptz NOT NULL DEFAULT now(),
87
+ updated_at timestamptz NOT NULL DEFAULT now(),
88
+ deleted_at timestamptz,
89
+ CHECK (email = lower(email))
90
+ );
91
+ CREATE UNIQUE INDEX users_email_uniq ON users (lower(email)) WHERE deleted_at IS NULL;
92
+
93
+ CREATE TABLE user_identities ( -- OAuth links (v1: github, google)
94
+ id uuid PRIMARY KEY,
95
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
96
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
97
+ provider text NOT NULL,
98
+ subject text NOT NULL,
99
+ created_at timestamptz NOT NULL DEFAULT now(),
100
+ UNIQUE (provider, subject)
101
+ );
102
+
103
+ CREATE TABLE memberships (
104
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
105
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
106
+ role snag_member_role NOT NULL DEFAULT 'member',
107
+ created_at timestamptz NOT NULL DEFAULT now(),
108
+ PRIMARY KEY (org_id, user_id)
109
+ );
110
+
111
+ CREATE TABLE user_sessions ( -- opaque server-side sessions, NOT JWT
112
+ id uuid PRIMARY KEY,
113
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
114
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
115
+ token_hash bytea NOT NULL UNIQUE, -- sha256 of the cookie value
116
+ user_agent text,
117
+ ip_hash bytea,
118
+ created_at timestamptz NOT NULL DEFAULT now(),
119
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
120
+ expires_at timestamptz NOT NULL,
121
+ revoked_at timestamptz
122
+ );
123
+ CREATE INDEX user_sessions_user_idx ON user_sessions (user_id) WHERE revoked_at IS NULL;
124
+
125
+ CREATE TABLE setup_tokens ( -- first-run claim, §6.10. 08 §3.4 prints it.
126
+ id uuid PRIMARY KEY,
127
+ token_hash bytea NOT NULL UNIQUE,
128
+ created_at timestamptz NOT NULL DEFAULT now(),
129
+ expires_at timestamptz,
130
+ consumed_at timestamptz
131
+ );
132
+
133
+ CREATE TABLE projects (
134
+ id uuid PRIMARY KEY,
135
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
136
+ uid text NOT NULL UNIQUE, -- PUBLIC site UID, 'prj_' + 12 (foundations §2.10)
137
+ name text NOT NULL,
138
+ status snag_project_status NOT NULL DEFAULT 'active',
139
+ kind text NOT NULL DEFAULT 'standard' CHECK (kind IN ('standard','demo')),
140
+
141
+ -- write gating (§4.2). New projects start 'open' and auto-downgrade after
142
+ -- SNAGSET_WRITE_MODE_GRACE_HOURS (default 24).
143
+ write_mode snag_write_mode NOT NULL DEFAULT 'open',
144
+ write_mode_downgrade_at timestamptz,
145
+ read_mode snag_write_mode, -- NULL = follow write_mode
146
+
147
+ -- human thread ids (foundations §2.10: Thread.key = 'SN-14')
148
+ -- 'SN', from names.ts's THREAD_REF_PREFIX. 'CD' was rename residue from the
149
+ -- cedilla era and contradicted this spec's own sentence six sections up
150
+ -- ('reads SN-14'); a project created under it would have minted CD-1.
151
+ key_prefix text NOT NULL DEFAULT 'SN' CHECK (key_prefix ~ '^[A-Z][A-Z0-9]{0,7}$'),
152
+ thread_seq integer NOT NULL DEFAULT 0,
153
+
154
+ -- IP hashing salt (§9.2, §13.1). Present from migration 0001, per §14.2b:
155
+ -- retrofitting it means every historical ip_hash was computed under a different
156
+ -- salt, which breaks erasure and comparability. 32 random bytes, per project.
157
+ -- The 'ip-hash' HKDF purpose stays deleted (foundations §7) -- this is data.
158
+ ip_salt bytea NOT NULL,
159
+
160
+ -- JSONB config blobs, all validated in the app layer (§3.10)
161
+ capture jsonb NOT NULL DEFAULT '{}'::jsonb, -- categories + masking (04 owns the vocabulary)
162
+ routing jsonb NOT NULL DEFAULT '{}'::jsonb, -- significant query params, normalisers (03)
163
+ branding jsonb NOT NULL DEFAULT '{}'::jsonb,
164
+ flags jsonb NOT NULL DEFAULT '{}'::jsonb,
165
+ settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- limits overrides, moderation, retiredUids
166
+
167
+ -- identity handshake (§4.6). Verification needs the PLAINTEXT secret, so it is
168
+ -- stored encrypted under HKDF(SNAGSET_SECRET_KEY,'credential-enc'), never only hashed.
169
+ identity_secret_enc bytea, -- NULL = HMAC identity disabled
170
+ identity_secret_enc_iv bytea,
171
+ identity_secret_prev_enc bytea, -- 24 h rotation overlap
172
+ identity_secret_prev_enc_iv bytea,
173
+ identity_secret_prev_expires_at timestamptz,
174
+ identity_secret_hint text, -- last 4 chars, for the dashboard only
175
+
176
+ -- cache control for the bootstrap (§7)
177
+ config_version integer NOT NULL DEFAULT 1,
178
+ config_etag text NOT NULL,
179
+ -- session invalidation epoch (§4.4)
180
+ session_epoch integer NOT NULL DEFAULT 1,
181
+ -- abuse
182
+ shared_suffix_origin boolean NOT NULL DEFAULT false,
183
+ paused_reason text,
184
+ -- retention & storage quota
185
+ retention_days integer CHECK (retention_days IS NULL OR retention_days BETWEEN 1 AND 3650),
186
+ storage_bytes_limit bigint NOT NULL DEFAULT 268435456, -- 256 MB (A9b); 0 = unlimited
187
+ storage_bytes_used bigint NOT NULL DEFAULT 0,
188
+ purge_after timestamptz,
189
+ created_at timestamptz NOT NULL DEFAULT now(),
190
+ updated_at timestamptz NOT NULL DEFAULT now(),
191
+ deleted_at timestamptz,
192
+ CHECK (uid ~ '^prj_[0-9abcdefghjkmnpqrstvwxyz]{12}$'),
193
+ CHECK (octet_length(ip_salt) = 32),
194
+ -- a project whose allow-list contains a shared public suffix (*.vercel.app)
195
+ -- may never be in 'open' write mode. See §4.9.
196
+ CHECK (NOT shared_suffix_origin OR write_mode <> 'open'),
197
+ CHECK (kind <> 'demo' OR retention_days IS NOT NULL),
198
+ CHECK ((identity_secret_enc IS NULL) = (identity_secret_enc_iv IS NULL))
199
+ );
200
+ CREATE INDEX projects_org_idx ON projects (org_id) WHERE deleted_at IS NULL;
201
+ CREATE INDEX projects_purge_idx ON projects (purge_after) WHERE purge_after IS NOT NULL;
202
+ CREATE INDEX projects_downgrade_idx ON projects (write_mode_downgrade_at)
203
+ WHERE write_mode_downgrade_at IS NOT NULL;
204
+
205
+ CREATE TABLE api_keys ( -- CI, relay, scripts. NOT the MCP server (§9.6).
206
+ id uuid PRIMARY KEY,
207
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
208
+ project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- NULL = org-wide
209
+ name text NOT NULL,
210
+ prefix text NOT NULL, -- first 8 chars after API_KEY_PREFIX, shown in UI
211
+ token_hash bytea NOT NULL UNIQUE,
212
+ scopes text[] NOT NULL DEFAULT '{}',
213
+ created_by uuid REFERENCES users(id),
214
+ created_at timestamptz NOT NULL DEFAULT now(),
215
+ last_used_at timestamptz,
216
+ last_used_ip bytea, -- hashed, never raw (§9.2)
217
+ expires_at timestamptz,
218
+ revoked_at timestamptz
219
+ );
220
+ CREATE INDEX api_keys_org_idx ON api_keys (org_id) WHERE revoked_at IS NULL;
221
+
222
+ -- foundations §14.2 assigns MCP credentials to 07's scoped token + this table;
223
+ -- 05 writes the DDL, 07 owns the scope vocabulary and the transports. Shape is
224
+ -- 07 §8.2's, verbatim, plus the remote-write gate §9.6 requires.
225
+ -- Ships in migration 0001; no route accepts an MCP token before v2 (§6.1 #42).
226
+ CREATE TABLE mcp_tokens (
227
+ id uuid PRIMARY KEY,
228
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
229
+ project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- NULL = org-wide
230
+ name text NOT NULL,
231
+ prefix text NOT NULL,
232
+ token_hash bytea NOT NULL UNIQUE,
233
+ scopes text[] NOT NULL DEFAULT '{}', -- vocabulary owned by 07
234
+ environments text[] NOT NULL DEFAULT '{*}', -- 07 §8.2
235
+ -- 07 §8.2's human-in-the-loop flag: the agent must confirm before writing.
236
+ confirm_writes boolean NOT NULL DEFAULT true,
237
+ -- §9.6: write scopes are refused over a remote (non-loopback) MCP transport
238
+ -- unless this is explicitly true. Distinct from confirm_writes: that one is a
239
+ -- UX obligation on the agent, this one is a server-enforced transport gate.
240
+ allow_remote_writes boolean NOT NULL DEFAULT false,
241
+ created_by uuid REFERENCES users(id),
242
+ created_at timestamptz NOT NULL DEFAULT now(),
243
+ last_used_at timestamptz,
244
+ last_used_ip bytea, -- hashed (07 F29 reads it)
245
+ expires_at timestamptz,
246
+ revoked_at timestamptz
247
+ );
248
+ CREATE INDEX mcp_tokens_org_idx ON mcp_tokens (org_id) WHERE revoked_at IS NULL;
249
+
250
+ CREATE TABLE project_origins (
251
+ id uuid PRIMARY KEY,
252
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
253
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
254
+ -- Exact origin ('https://staging.acme.com') or single-level wildcard host
255
+ -- ('https://*.acme.com'). Scheme is mandatory. Port is part of the origin.
256
+ pattern text NOT NULL,
257
+ is_wildcard boolean NOT NULL DEFAULT false,
258
+ label text,
259
+ created_at timestamptz NOT NULL DEFAULT now(),
260
+ UNIQUE (project_id, pattern),
261
+ CHECK (pattern ~ '^https?://[a-z0-9*.\-]+(:[0-9]{1,5})?$'),
262
+ CHECK (pattern = lower(pattern))
263
+ );
264
+ CREATE INDEX project_origins_project_idx ON project_origins (project_id);
265
+
266
+ CREATE TABLE environments (
267
+ id uuid PRIMARY KEY,
268
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
269
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
270
+ key text NOT NULL, -- 'preview','staging','production'
271
+ label text NOT NULL,
272
+ -- origin/branch matchers used to auto-classify an incoming page (§6.5)
273
+ matchers jsonb NOT NULL DEFAULT '[]'::jsonb,
274
+ is_default boolean NOT NULL DEFAULT false,
275
+ created_at timestamptz NOT NULL DEFAULT now(),
276
+ deleted_at timestamptz,
277
+ UNIQUE (project_id, key),
278
+ CHECK (key ~ '^[a-z0-9][a-z0-9._-]{0,62}$')
279
+ );
280
+ CREATE UNIQUE INDEX environments_one_default ON environments (project_id) WHERE is_default;
281
+
282
+ CREATE TABLE rounds (
283
+ id uuid PRIMARY KEY, -- wire form 'rnd_…' (foundations §2.10)
284
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
285
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
286
+ -- Display order and the default: the latest round is the one you land in.
287
+ -- A per-project sequence, minted at creation, never recomputed.
288
+ number integer NOT NULL,
289
+ -- "Round 1" is the generated name; the studio may rename it "Pre-launch".
290
+ name text NOT NULL,
291
+ -- Per-round thread numbering (01 §5.2, AC-1-12): "the round number is a
292
+ -- per-round sequence minted at creation and never recomputed; a deleted
293
+ -- thread leaves a gap". The counter lives here rather than being computed
294
+ -- from the threads, because computing it is what makes numbers move.
295
+ thread_seq integer NOT NULL DEFAULT 0,
296
+ -- A closed round still shows its unresolved count (D2's one safeguard) and
297
+ -- is still selectable; closing only means new threads go elsewhere.
298
+ closed_at timestamptz,
299
+ -- A STUDIO act, so `users`, not `reviewers`: a reviewer comments, a round is
300
+ -- opened and closed from the dashboard. It is also the only workable
301
+ -- reference at setup, where the first round is created inside the claim
302
+ -- transaction and no reviewer exists yet (§6.10 step 2) — a reviewers FK
303
+ -- there is unsatisfiable, which is how this was caught.
304
+ -- NULLable with SET NULL: the round outlives whoever opened it.
305
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
306
+ created_at timestamptz NOT NULL DEFAULT now(),
307
+ updated_at timestamptz NOT NULL DEFAULT now(),
308
+ deleted_at timestamptz,
309
+ UNIQUE (project_id, number),
310
+ CHECK (number > 0),
311
+ CHECK (thread_seq >= 0)
312
+ );
313
+
314
+ -- The selector reads this: rounds of a project, newest first.
315
+ CREATE INDEX rounds_project_idx ON rounds (project_id, number DESC) WHERE deleted_at IS NULL;
316
+
317
+ CREATE TABLE review_links (
318
+ id uuid PRIMARY KEY,
319
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
320
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
321
+ token_hash bytea NOT NULL UNIQUE, -- sha256(raw token). Raw is never stored.
322
+ -- The link's LABEL, not an invitee's name: "Client review — round 2".
323
+ -- D3 removed per-person minting, and a field that asks for a person's name
324
+ -- reintroduces invite management through the back door. The dashboard shows
325
+ -- ONE current link per project (M2-27) and offers revoke-and-replace; the
326
+ -- table stays many-rowed so an outgoing link can stay live through a
327
+ -- handover, which M2-27's own DoD requires ("minting a replacement link does
328
+ -- not revoke the old one").
329
+ name text NOT NULL,
330
+ -- Optional, and only so §6.4b's placeholder attach can match on lower(email)
331
+ -- when the studio happens to know it. Never required to mint.
332
+ email text,
333
+ environments text[] NOT NULL DEFAULT '{*}',
334
+ -- D2/M2-18: a link may be PINNED to a round, so a client invited for round 2
335
+ -- lands in round 2 rather than in whatever is active when they open it.
336
+ -- NULL = every round, which is the default and the common case.
337
+ --
338
+ -- Present from 0001 rather than added later: §3 IS the migration
339
+ -- (apps/server/scripts/gen-migration.mjs generates it from these blocks, and
340
+ -- CI runs gen:migration:check), so a column added afterwards is a second
341
+ -- migration on a table this feature owns. The plan asserted round pinning in
342
+ -- M2-18's DoD while no column existed to carry it — caught by reading the
343
+ -- DoD against the DDL.
344
+ --
345
+ -- SET NULL, not CASCADE: deleting a round must not silently delete the link
346
+ -- a client is holding. It widens to every round instead, which is visible.
347
+ round_id uuid REFERENCES rounds(id) ON DELETE SET NULL,
348
+ -- The DEFAULT is the client link. A "team link" -- the studio reviewing its
349
+ -- own work through the widget rather than the dashboard -- additionally
350
+ -- carries 'thread:private' and 'thread:assign' (§6.4a, §6.4b). Those two are
351
+ -- the ONLY route by which a widget session sees or writes team-only content.
352
+ permissions text[] NOT NULL DEFAULT '{thread:create,comment:create,thread:resolve}',
353
+ allow_name_override boolean NOT NULL DEFAULT true,
354
+ max_uses integer, -- NULL = unlimited. Counts PEOPLE (§4.5).
355
+ use_count integer NOT NULL DEFAULT 0,
356
+ created_by uuid REFERENCES users(id),
357
+ created_at timestamptz NOT NULL DEFAULT now(),
358
+ -- default now() + SNAGSET_REVIEW_LINK_TTL_DAYS (14), clamped by
359
+ -- SNAGSET_REVIEW_LINK_MAX_TTL_DAYS (90). Foundations §7's two numbers.
360
+ expires_at timestamptz NOT NULL,
361
+ revoked_at timestamptz,
362
+ last_used_at timestamptz,
363
+ CHECK (expires_at > created_at),
364
+ CHECK (max_uses IS NULL OR max_uses > 0)
365
+ );
366
+ CREATE INDEX review_links_project_idx ON review_links (project_id, created_at DESC);
367
+ CREATE INDEX review_links_expiry_idx ON review_links (expires_at) WHERE revoked_at IS NULL;
368
+
369
+ CREATE TABLE reviewers (
370
+ id uuid PRIMARY KEY,
371
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
372
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
373
+ kind snag_reviewer_kind NOT NULL,
374
+ name text NOT NULL,
375
+ email text,
376
+ avatar_url text,
377
+ external_id text, -- host-app user id, kind='verified' only
378
+ review_link_id uuid REFERENCES review_links(id) ON DELETE SET NULL,
379
+ -- STUDIO IDENTITY (FOLDBACK §A3). A dashboard user's reviewer row in this
380
+ -- project. Set => this person is team: they are in the assignee pool without
381
+ -- having commented, and they are the half of §6.4b's union that is not
382
+ -- "everyone who has commented". It is also the structural answer to "is this
383
+ -- person studio?", which §6.4a's private predicate needs.
384
+ --
385
+ -- RESTRICT, not SET NULL, and the CHECKs below are why. `user_id` is not a
386
+ -- decoration on a verified row, it IS the verification — "vouched for by the
387
+ -- host app (external_id) OR one of our own dashboard users (user_id)". Null
388
+ -- it and the row asserts a verification nothing backs, which is exactly what
389
+ -- `CHECK (kind <> 'verified' OR external_id IS NOT NULL OR user_id IS NOT
390
+ -- NULL)` forbids. Draft 4 had SET NULL here, and the two rules contradicted
391
+ -- for one reachable combination: a studio member replies from the dashboard
392
+ -- (kind='verified', user_id set, external_id null), then somebody deletes
393
+ -- that member — the FK nulls the column, the CHECK rejects the row, and the
394
+ -- DELETE fails with a constraint violation instead of a sentence. Found on
395
+ -- 2026-08-21 by deleting a throwaway owner after a test.
396
+ --
397
+ -- So a person who has authored work cannot be HARD-deleted, which is the
398
+ -- honest rule: `users.deleted_at` exists, `ownerAuth` already filters on it,
399
+ -- and soft delete is how somebody leaves a studio without their replies
400
+ -- losing their name.
401
+ user_id uuid REFERENCES users(id) ON DELETE RESTRICT,
402
+ -- PLACEHOLDER (DESIGN.md Q4's '+ Add person'). A named person who has not yet
403
+ -- written anything and holds no credential: assignable, not authenticable.
404
+ -- Attaches to a real identity on a later 'link'/'verified' exchange (§6.4b).
405
+ --
406
+ -- RESTRICT for the same reason one line up, via the SECOND check: a
407
+ -- placeholder's only claim to existing is `invited_by`, so nulling it leaves
408
+ -- a row with no credential, no user and no inviter — which
409
+ -- `CHECK (kind = 'verified' OR holder_secret_hash IS NOT NULL OR user_id IS
410
+ -- NOT NULL OR invited_by IS NOT NULL)` forbids. Same shape, same fix, found
411
+ -- by reading rather than by breaking.
412
+ invited_by uuid REFERENCES users(id) ON DELETE RESTRICT,
413
+ -- One row per PERSON. For kind='guest' and kind='link' this is sha256 of a
414
+ -- 32-byte secret the holder keeps in localStorage 'snag:identity' (§4.4.1, §4.5).
415
+ holder_secret_hash bytea,
416
+ -- MCP/agent authorship (§9.6, v2 feature; columns exist from 0001 so the
417
+ -- behaviour and its CHECK ship together rather than as a later ALTER).
418
+ is_agent boolean NOT NULL DEFAULT false,
419
+ agent_mcp_token_id uuid REFERENCES mcp_tokens(id) ON DELETE SET NULL,
420
+ -- per-identity revocation without nuking the project epoch (§4.6)
421
+ identity_valid_after timestamptz,
422
+ blocked_at timestamptz,
423
+ erased_at timestamptz, -- §13 subject erasure
424
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
425
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
426
+ thread_count integer NOT NULL DEFAULT 0,
427
+ comment_count integer NOT NULL DEFAULT 0,
428
+ -- A verified reviewer is vouched for by the host app (external_id) OR is one
429
+ -- of our own dashboard users (user_id). Draft 4 required external_id, which
430
+ -- left a studio member with no representable reviewer identity at all.
431
+ CHECK (kind <> 'verified' OR external_id IS NOT NULL OR user_id IS NOT NULL),
432
+ -- Every non-verified row is one of: a credential holder, a studio member, or
433
+ -- a placeholder somebody named. Nothing else may exist.
434
+ CHECK (kind = 'verified' OR holder_secret_hash IS NOT NULL
435
+ OR user_id IS NOT NULL
436
+ OR invited_by IS NOT NULL),
437
+ CHECK (kind <> 'link' OR review_link_id IS NOT NULL),
438
+ CHECK (NOT is_agent OR kind = 'guest'),
439
+ -- A studio identity is never an agent and never a placeholder.
440
+ CHECK (user_id IS NULL OR (NOT is_agent AND invited_by IS NULL))
441
+ );
442
+ CREATE UNIQUE INDEX reviewers_verified_uniq ON reviewers (project_id, external_id)
443
+ WHERE kind = 'verified' AND external_id IS NOT NULL;
444
+ -- One studio reviewer row per user per project (FOLDBACK §A3).
445
+ CREATE UNIQUE INDEX reviewers_user_uniq ON reviewers (project_id, user_id)
446
+ WHERE user_id IS NOT NULL;
447
+ -- ONE ROW PER PERSON PER LINK, not one row per link. See §4.5.
448
+ CREATE UNIQUE INDEX reviewers_link_holder_uniq
449
+ ON reviewers (project_id, review_link_id, holder_secret_hash) WHERE kind = 'link';
450
+ CREATE UNIQUE INDEX reviewers_guest_holder_uniq
451
+ ON reviewers (project_id, holder_secret_hash)
452
+ WHERE kind = 'guest' AND holder_secret_hash IS NOT NULL;
453
+ CREATE INDEX reviewers_project_idx ON reviewers (project_id, last_seen_at DESC);
454
+ CREATE INDEX reviewers_link_idx ON reviewers (review_link_id) WHERE kind = 'link';
455
+ -- The assignee pool query (§6.4b): studio people first, then anyone who wrote.
456
+ CREATE INDEX reviewers_pool_idx ON reviewers (project_id, last_seen_at DESC)
457
+ WHERE blocked_at IS NULL AND erased_at IS NULL
458
+ AND (user_id IS NOT NULL OR invited_by IS NOT NULL
459
+ OR thread_count > 0 OR comment_count > 0);
460
+ -- Attaching a placeholder to the person it was created for (§6.4b), by an email
461
+ -- the SERVER supplied -- never one a guest typed about themselves.
462
+ CREATE INDEX reviewers_email_idx ON reviewers (project_id, lower(email))
463
+ WHERE email IS NOT NULL AND erased_at IS NULL;
464
+
465
+
466
+
467
+ CREATE TABLE threads (
468
+ id uuid PRIMARY KEY, -- client-minted UUIDv7; wire form 'thr_…'
469
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
470
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
471
+ key text NOT NULL, -- 'SN-14' (foundations §2.10)
472
+ -- Monotonic revision, bumped in the same transaction as ANY mutation of this
473
+ -- thread or of its comments (§14.2b; 07 §2.5 builds its outbox dedupe_key from
474
+ -- it). Also the ETag source for If-Match (§6.4).
475
+ version integer NOT NULL DEFAULT 1,
476
+ environment_id uuid NOT NULL REFERENCES environments(id) ON DELETE RESTRICT,
477
+ -- Thread.scope.round (foundations §5, D2). NOT NULL and RESTRICT: a thread
478
+ -- belongs to exactly one round and threads never move, so there is no
479
+ -- reassignment path and deleting a round with threads in it is refused
480
+ -- rather than silently orphaning them.
481
+ round_id uuid NOT NULL REFERENCES rounds(id) ON DELETE RESTRICT,
482
+ -- Creation order WITHIN the round (01 §5.2) — the number on the pin. Minted
483
+ -- from rounds.thread_seq at creation, never recomputed: AC-1-12 requires
484
+ -- that deleting comment 4 leaves comment 5 called 5, everywhere.
485
+ round_number integer NOT NULL,
486
+ branch text,
487
+ -- AnchorRecord (foundations §5), stored verbatim, upcast on read (§11.3)
488
+ anchor jsonb NOT NULL,
489
+ anchor_schema text NOT NULL DEFAULT 'snagset.v1',
490
+ anchor_state snag_anchor_state NOT NULL DEFAULT 'anchored',
491
+ -- monotonic-merge scope for anchor_state (§6.7). NULL = no build reported yet.
492
+ anchor_state_build text,
493
+ last_anchor_check timestamptz,
494
+ -- CaptureContext (foundations §5), already redacted client-side (04/A11)
495
+ context jsonb NOT NULL,
496
+ context_schema text NOT NULL DEFAULT 'snagset.v1',
497
+ status snag_thread_status NOT NULL DEFAULT 'open',
498
+ moderation snag_moderation NOT NULL DEFAULT 'visible',
499
+ -- TEAM-ONLY (foundations §5, FOLDBACK §A4). Excluded for a client AT THE DATA
500
+ -- LAYER by §6.4a's predicate, never rendered-then-hidden. Studio-only to set.
501
+ private boolean NOT NULL DEFAULT false,
502
+ created_by uuid NOT NULL REFERENCES reviewers(id) ON DELETE RESTRICT,
503
+ -- Pool is studio people ∪ everyone who has commented (FOLDBACK §A3, §6.4b).
504
+ assignee_id uuid REFERENCES reviewers(id) ON DELETE SET NULL, -- foundations §5
505
+ -- DEFERRED to v2 (FOLDBACK §A2). The column, the CHECK and foundations §5's
506
+ -- field all survive so that re-adding is additive; NO v1 route reads or
507
+ -- writes it, and no v1 request body may carry it (§6.2, §6.4, §11.1 rule 3).
508
+ labels text[] NOT NULL DEFAULT '{}', -- foundations §5
509
+ resolved_by uuid REFERENCES reviewers(id) ON DELETE SET NULL,
510
+ comment_count integer NOT NULL DEFAULT 0,
511
+ -- The same count under §6.4a's predicate. Maintained in the same transaction
512
+ -- as comment_count; it is what a client is told, so that a client can never
513
+ -- count replies it is not allowed to read (§6.4a).
514
+ comment_count_public integer NOT NULL DEFAULT 0,
515
+ -- derived, indexable projections of the anchor. Generated → always consistent.
516
+ route text GENERATED ALWAYS AS (anchor #>> '{page,route}') STORED,
517
+ pathname text GENERATED ALWAYS AS (anchor #>> '{page,pathname}') STORED,
518
+ origin text GENERATED ALWAYS AS (anchor #>> '{page,origin}') STORED,
519
+ -- AnchorRecord.label (foundations §5, FOLDBACK §A7): '"Browse the autumn
520
+ -- range"', 'an image', 'the h1'. Projected because the orphan list, the
521
+ -- export and v2 search all read it AFTER the element it describes is gone.
522
+ -- DISPLAY ONLY. Never a re-anchor input -- matching is 03's, and this string
523
+ -- is a human description, not a selector.
524
+ anchor_label text GENERATED ALWAYS AS (anchor #>> '{label}') STORED,
525
+ created_at timestamptz NOT NULL DEFAULT now(),
526
+ updated_at timestamptz NOT NULL DEFAULT now(),
527
+ resolved_at timestamptz,
528
+ deleted_at timestamptz,
529
+ UNIQUE (project_id, key),
530
+ UNIQUE (round_id, round_number),
531
+ CHECK (version > 0),
532
+ CHECK (round_number > 0),
533
+ CHECK (jsonb_typeof(anchor) = 'object' AND anchor ? 'offset' AND anchor ? 'page'),
534
+ CHECK (jsonb_typeof(context) = 'object'),
535
+ CHECK (octet_length(anchor::text) <= 32768),
536
+ CHECK (octet_length(context::text) <= 262144),
537
+ CHECK (array_length(labels, 1) IS NULL OR array_length(labels, 1) <= 32),
538
+ CHECK ((status = 'resolved') = (resolved_at IS NOT NULL)),
539
+ CHECK (comment_count_public >= 0 AND comment_count_public <= comment_count),
540
+ -- A team-only thread's replies are all team-only by construction, so its
541
+ -- public count is zero. Cheap, and it makes the leak in §6.4a impossible
542
+ -- rather than merely unlikely.
543
+ CHECK (NOT private OR comment_count_public = 0)
544
+ );
545
+
546
+ CREATE TABLE comments (
547
+ id uuid PRIMARY KEY, -- wire form 'cmt_…'
548
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
549
+ thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
550
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -- denormalised for RLS + rate accounting
551
+ body text, -- NULL when soft-deleted (tombstone)
552
+ body_hash bytea, -- sha256, duplicate detection (§9.4)
553
+ -- parsed at write time from @[Name](snag:user/<reviewerId>) in the body, so
554
+ -- notification fan-out never re-parses markdown (foundations §5, §2.11)
555
+ mentions uuid[] NOT NULL DEFAULT '{}',
556
+ author_id uuid NOT NULL REFERENCES reviewers(id) ON DELETE RESTRICT,
557
+ moderation snag_moderation NOT NULL DEFAULT 'visible',
558
+ -- TEAM-ONLY REPLY (foundations §5, FOLDBACK §A5). A private reply on a PUBLIC
559
+ -- thread is studio-only; the thread itself stays visible to the client. Same
560
+ -- data-layer exclusion as threads (§6.4a).
561
+ private boolean NOT NULL DEFAULT false,
562
+ seq integer NOT NULL, -- 1-based position inside the thread
563
+ created_at timestamptz NOT NULL DEFAULT now(),
564
+ updated_at timestamptz NOT NULL DEFAULT now(),
565
+ edited_at timestamptz,
566
+ deleted_at timestamptz,
567
+ UNIQUE (thread_id, seq),
568
+ CHECK (body IS NULL OR char_length(body) <= 10000), -- foundations §6 budget
569
+ CHECK ((body IS NULL) = (deleted_at IS NOT NULL))
570
+ );
571
+
572
+ CREATE TABLE attachments (
573
+ id uuid PRIMARY KEY, -- client-minted; wire form 'att_…'
574
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
575
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
576
+ thread_id uuid REFERENCES threads(id) ON DELETE CASCADE,
577
+ comment_id uuid REFERENCES comments(id) ON DELETE CASCADE,
578
+ kind snag_attachment_kind NOT NULL,
579
+ state snag_attachment_state NOT NULL DEFAULT 'pending',
580
+ storage_key text NOT NULL UNIQUE,
581
+ content_type text NOT NULL,
582
+ original_filename text, -- L3: downloads need a name; keys are hashes
583
+ storage_backend text NOT NULL DEFAULT 's3'
584
+ CHECK (storage_backend = 's3'), -- D18: bytes never live in Postgres
585
+ bytes bigint NOT NULL CHECK (bytes > 0), -- per-kind ceilings below
586
+ width integer,
587
+ height integer,
588
+ sha256 bytea NOT NULL,
589
+ uploaded_by uuid REFERENCES reviewers(id) ON DELETE SET NULL,
590
+ created_at timestamptz NOT NULL DEFAULT now(),
591
+ bound_at timestamptz,
592
+ deleted_at timestamptz,
593
+ CHECK ((state = 'bound') = (bound_at IS NOT NULL)),
594
+ -- FOLDBACK §A6: an attachment hangs off EXACTLY ONE of a thread or a comment.
595
+ -- Draft 4 left both nullable with nothing enforcing the choice, which is how
596
+ -- a screenshot ends up counted twice and rendered in two places.
597
+ CHECK (state <> 'bound' OR ((thread_id IS NOT NULL) <> (comment_id IS NOT NULL))),
598
+ CHECK (state = 'bound' OR (thread_id IS NULL AND comment_id IS NULL)),
599
+ -- D18: image kinds keep the closed type list and the 256 KB encode budget.
600
+ -- kind='file' stores as application/octet-stream in the bucket (content_type here
601
+ -- is an icon hint only, never a serving header) and carries the operator cap,
602
+ -- enforced at presign + complete time rather than in DDL.
603
+ CHECK (kind NOT IN ('screenshot','image') OR content_type IN ('image/webp','image/png','image/jpeg')),
604
+ CHECK (kind NOT IN ('screenshot','image') OR bytes <= 262144),
605
+ CHECK (kind <> 'file' OR original_filename IS NOT NULL), -- L3: the download filename
606
+ CHECK (kind <> 'dom-snapshot') -- dropped by the v2 migration that ships dom-snapshot
607
+ );
608
+ CREATE INDEX attachments_reap_idx ON attachments (created_at) WHERE state = 'pending';
609
+ CREATE INDEX attachments_dedupe_idx ON attachments (project_id, sha256);
610
+ -- Enforcing "max 3 per item" (FOLDBACK §A6) at bind time; see §6.6's bind rules.
611
+ CREATE INDEX attachments_thread_idx ON attachments (thread_id) WHERE deleted_at IS NULL;
612
+ CREATE INDEX attachments_comment_idx ON attachments (comment_id) WHERE deleted_at IS NULL;
613
+
614
+ -- Revocable, per-consumer references to an attachment (§6.6, §6.8). Foundations
615
+ -- §14.2: ONE mechanism for embedding a screenshot in an external system.
616
+ -- 05 owns the table and the routes; 07 and 08 consume and define nothing.
617
+ CREATE TABLE attachment_shares (
618
+ id uuid PRIMARY KEY,
619
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
620
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
621
+ attachment_id uuid NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
622
+ provider snag_provider NOT NULL,
623
+ external_ref_id uuid, -- the issue/message this was embedded in
624
+ created_at timestamptz NOT NULL DEFAULT now(),
625
+ expires_at timestamptz, -- NULL = until revoked (SNAGSET_ATTACHMENT_SHARE_TTL_DAYS=0)
626
+ revoked_at timestamptz,
627
+ last_fetched_at timestamptz,
628
+ fetch_count integer NOT NULL DEFAULT 0,
629
+ UNIQUE (attachment_id, provider, external_ref_id)
630
+ );
631
+ CREATE INDEX attachment_shares_att_idx ON attachment_shares (attachment_id)
632
+ WHERE revoked_at IS NULL;
633
+
634
+ CREATE TABLE integrations (
635
+ id uuid PRIMARY KEY,
636
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
637
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
638
+ provider snag_provider NOT NULL,
639
+ -- Non-secret config (repo, labels, channel id, attachment share TTL,
640
+ -- includeScreenshot). 07 owns the shape; §3.10 validates per provider.
641
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
642
+ credentials bytea, -- AES-256-GCM, HKDF(SNAGSET_SECRET_KEY,'credential-enc')
643
+ credentials_iv bytea,
644
+ enabled boolean NOT NULL DEFAULT true,
645
+ install_state text NOT NULL DEFAULT 'ok', -- ok | needs_reauth | error
646
+ last_error text,
647
+ created_at timestamptz NOT NULL DEFAULT now(),
648
+ updated_at timestamptz NOT NULL DEFAULT now(),
649
+ deleted_at timestamptz,
650
+ UNIQUE (project_id, provider)
651
+ );
652
+
653
+ CREATE TABLE external_refs (
654
+ id uuid PRIMARY KEY,
655
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
656
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
657
+ thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
658
+ provider snag_provider NOT NULL,
659
+ kind snag_ref_kind NOT NULL, -- foundations §5, five members
660
+ status snag_ref_status NOT NULL DEFAULT 'synced',
661
+ external_id text NOT NULL,
662
+ url text NOT NULL,
663
+ synced_at timestamptz NOT NULL DEFAULT now(),
664
+ last_error text,
665
+ -- §14.2b: the compound unique is the idempotency key for "we already recorded
666
+ -- THIS remote object". It permits a thread to carry a GitHub issue AND several
667
+ -- PR links AND attachment backlinks.
668
+ UNIQUE (thread_id, provider, kind, external_id)
669
+ );
670
+ -- …and exactly one canonical issue and one canonical message per (thread, provider),
671
+ -- because "which issue is this thread's issue?" must have one answer. Draft 3's
672
+ -- blanket UNIQUE (thread_id, provider, kind) forbade an issue AND a PR; this pair
673
+ -- is 07's shape and permits it.
674
+ CREATE UNIQUE INDEX external_refs_canonical
675
+ ON external_refs (thread_id, provider, kind)
676
+ WHERE kind IN ('issue','message');
677
+ CREATE INDEX external_refs_lookup ON external_refs (provider, external_id);
678
+
679
+ -- Transactional outbox (v1), 07 §2.5's shape verbatim. Rows are inserted in the
680
+ -- SAME transaction as the thread/comment mutation -- that transactionality plus
681
+ -- the unique below IS the idempotency guarantee. §6.2 step 10 does it.
682
+ CREATE TABLE outbox (
683
+ id uuid PRIMARY KEY, -- UUIDv7. NOT bigserial (foundations §2.10).
684
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
685
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
686
+ integration_id uuid REFERENCES integrations(id) ON DELETE CASCADE,
687
+ thread_id uuid REFERENCES threads(id) ON DELETE CASCADE,
688
+ provider snag_provider NOT NULL,
689
+ topic text NOT NULL, -- vocabulary owned by 07
690
+ -- 07's dedupe key: '<topic>:<entityId>:<threads.version>'. Its COMPOSITION is
691
+ -- 07's; its uniqueness is enforced here.
692
+ dedupe_key text NOT NULL,
693
+ event jsonb NOT NULL DEFAULT '{}'::jsonb, -- the raw DomainEvent, sanitised at push time
694
+ status snag_outbox_status NOT NULL DEFAULT 'pending',
695
+ attempts integer NOT NULL DEFAULT 0,
696
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
697
+ last_error text,
698
+ created_at timestamptz NOT NULL DEFAULT now(),
699
+ completed_at timestamptz,
700
+ -- THE idempotency guarantee against duplicate issue creation (§14.2b).
701
+ -- Draft 3 dropped it while still calling the outbox "the idempotency guarantee".
702
+ UNIQUE (provider, dedupe_key),
703
+ CHECK (octet_length(event::text) <= 65536)
704
+ );
705
+ CREATE INDEX outbox_due ON outbox (status, next_attempt_at) WHERE status = 'pending';
706
+
707
+ -- Sync ledger, 07 §2.5's shape. OUTBOUND half is v1 (it is what makes v2's loop
708
+ -- prevention a pure addition rather than a retrofit); the INBOUND half ships with
709
+ -- two-way sync in v2 (foundations §14.2).
710
+ CREATE TABLE sync_ledger (
711
+ id uuid PRIMARY KEY,
712
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
713
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
714
+ provider snag_provider NOT NULL,
715
+ direction snag_sync_direction NOT NULL,
716
+ delivery_id text NOT NULL, -- provider delivery id (outbound: our outbox id)
717
+ entity_kind snag_sync_entity NOT NULL,
718
+ entity_id uuid, -- thread or comment row id
719
+ external_id text,
720
+ -- THE loop-prevention key (§14.2b): sha256 of the normalised body. Without it
721
+ -- a comment synced out and read back in is re-applied, and echoes forever.
722
+ content_hash bytea NOT NULL,
723
+ thread_id uuid REFERENCES threads(id) ON DELETE SET NULL,
724
+ created_at timestamptz NOT NULL DEFAULT now(),
725
+ UNIQUE (provider, direction, delivery_id)
726
+ );
727
+ CREATE INDEX sync_ledger_content
728
+ ON sync_ledger (provider, entity_kind, content_hash, created_at DESC);
729
+
730
+ -- 07's name (draft 3 called this 'webhooks'), §14.2b. The ROUTE stays
731
+ -- /projects/{uid}/webhooks (endpoint 26) because that is the resource name 07
732
+ -- cites and customers type; the table is the endpoint record.
733
+ CREATE TABLE webhook_endpoints (
734
+ id uuid PRIMARY KEY,
735
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
736
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
737
+ url text NOT NULL,
738
+ secret bytea NOT NULL, -- encrypted under 'credential-enc'
739
+ secret_iv bytea NOT NULL,
740
+ event_types text[] NOT NULL DEFAULT '{*}',
741
+ enabled boolean NOT NULL DEFAULT true,
742
+ failure_streak integer NOT NULL DEFAULT 0,
743
+ created_at timestamptz NOT NULL DEFAULT now(),
744
+ disabled_at timestamptz,
745
+ disabled_reason text,
746
+ -- http:// is permitted AT THE DB LEVEL so that SNAGSET_WEBHOOKS_ALLOW_HTTP
747
+ -- (foundations §2.9, registered in 08 §4.6, default false) is not dead
748
+ -- configuration. The scheme rule is enforced in the app (§6.12) together with
749
+ -- the SSRF address-space guard, which is a separate switch.
750
+ CHECK (url ~ '^https?://')
751
+ );
752
+ CREATE INDEX webhook_endpoints_project_idx ON webhook_endpoints (project_id)
753
+ WHERE disabled_at IS NULL;
754
+
755
+ CREATE TABLE events (
756
+ id uuid PRIMARY KEY, -- UUIDv7; the row's identity
757
+ seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- total-order cursor (webhook replay, audit paging); pre-R6 it also fed SSE
758
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
759
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
760
+ environment_id uuid REFERENCES environments(id) ON DELETE SET NULL,
761
+ type text NOT NULL, -- see §8.2
762
+ actor_kind text NOT NULL, -- reviewer|user|system|integration
763
+ actor_id uuid,
764
+ subject_type text NOT NULL, -- thread|comment|project|review_link|attachment
765
+ subject_id uuid,
766
+ route text,
767
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
768
+ ip_hash bytea, -- sha256(ip || projects.ip_salt); never raw IP
769
+ user_agent text,
770
+ created_at timestamptz NOT NULL DEFAULT now(),
771
+ CHECK (octet_length(payload::text) <= 16384)
772
+ );
773
+ CREATE INDEX events_project_seq ON events (project_id, seq);
774
+
775
+ -- 07 §7.4's delivery log, verbatim.
776
+ CREATE TABLE webhook_deliveries (
777
+ id uuid PRIMARY KEY, -- this IS the Standard Webhooks 'webhook-id'
778
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
779
+ endpoint_id uuid NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
780
+ event_id uuid REFERENCES events(id) ON DELETE SET NULL,
781
+ event_type text NOT NULL,
782
+ attempts integer NOT NULL DEFAULT 0,
783
+ status text NOT NULL DEFAULT 'pending', -- pending|delivered|failed|dropped
784
+ request_headers jsonb NOT NULL DEFAULT '{}'::jsonb, -- secret-free by construction
785
+ request_body_hash bytea NOT NULL,
786
+ request_body_excerpt text, -- first 8 KB of the ALREADY-SANITISED payload
787
+ response_code integer,
788
+ response_body_excerpt text, -- first 2 KB
789
+ duration_ms integer,
790
+ error_class text,
791
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
792
+ created_at timestamptz NOT NULL DEFAULT now(),
793
+ delivered_at timestamptz,
794
+ replay_of uuid REFERENCES webhook_deliveries(id) ON DELETE SET NULL,
795
+ CHECK (octet_length(coalesce(request_body_excerpt,'')) <= 8192),
796
+ CHECK (octet_length(coalesce(response_body_excerpt,'')) <= 2048)
797
+ );
798
+ CREATE INDEX webhook_deliveries_due ON webhook_deliveries (next_attempt_at) WHERE status = 'pending';
799
+ CREATE INDEX webhook_deliveries_endpoint ON webhook_deliveries (endpoint_id, created_at DESC);
800
+
801
+ CREATE INDEX events_env_seq ON events (project_id, environment_id, seq);
802
+ CREATE INDEX events_subject ON events (subject_type, subject_id, seq DESC);
803
+ CREATE INDEX events_created ON events (created_at);
804
+
805
+ -- Append-only re-anchor outcomes. Backs Thread.anchorHistory (foundations §5),
806
+ -- 03's re-anchor reporting, and 08 §6's cedilla_anchor_resolutions_total metric.
807
+ CREATE TABLE anchor_events (
808
+ id uuid PRIMARY KEY,
809
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
810
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
811
+ thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
812
+ at timestamptz NOT NULL DEFAULT now(),
813
+ state snag_anchor_state NOT NULL, -- settled only; 'pending' is rejected upstream
814
+ confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
815
+ strategy text NOT NULL, -- validated against 03's AnchorStrategy enum
816
+ actor_kind text NOT NULL, -- 'runtime' (automatic) | 'reviewer' (repair, v2)
817
+ actor_id uuid REFERENCES reviewers(id) ON DELETE SET NULL,
818
+ build text, -- CaptureContext.build.commit when supplied
819
+ ms integer, -- cascade wall-clock, for 03's telemetry
820
+ applied boolean NOT NULL DEFAULT true, -- false when the monotonic merge declined it
821
+ previous_anchor jsonb -- only on 'manual' repair, so a bad repair is undoable
822
+ );
823
+ CREATE INDEX anchor_events_thread_idx ON anchor_events (thread_id, at DESC);
824
+ CREATE INDEX anchor_events_applied_idx ON anchor_events (thread_id, at DESC) WHERE applied;
825
+ CREATE INDEX anchor_events_project_idx ON anchor_events (project_id, at DESC);
826
+
827
+ CREATE TABLE idempotency_keys (
828
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
829
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
830
+ key text NOT NULL,
831
+ method text NOT NULL,
832
+ path text NOT NULL,
833
+ request_hash bytea NOT NULL, -- sha256(body) — detects key reuse with a different body
834
+ status_code integer,
835
+ response_body jsonb,
836
+ created_at timestamptz NOT NULL DEFAULT now(),
837
+ expires_at timestamptz NOT NULL,
838
+ PRIMARY KEY (project_id, key)
839
+ );
840
+ CREATE INDEX idempotency_expiry ON idempotency_keys (expires_at);
841
+
842
+ CREATE TABLE identity_nonces ( -- HMAC handshake replay protection (§4.6)
843
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
844
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
845
+ nonce text NOT NULL,
846
+ expires_at timestamptz NOT NULL,
847
+ PRIMARY KEY (project_id, nonce)
848
+ );
849
+ CREATE INDEX identity_nonces_expiry ON identity_nonces (expires_at);
850
+
851
+ -- stream_tickets: DELETED with the SSE fan-out (sweep R6). The DDL that stood
852
+ -- here is preserved in §8.3's history note; migration 0001 does not create it.
853
+
854
+ CREATE TABLE export_jobs ( -- endpoint 28b (async export)
855
+ id uuid PRIMARY KEY,
856
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
857
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
858
+ requested_by uuid REFERENCES users(id) ON DELETE SET NULL,
859
+ format text NOT NULL CHECK (format IN ('json','md','csv','zip')),
860
+ status text NOT NULL DEFAULT 'queued', -- queued|running|ready|failed|expired
861
+ storage_key text,
862
+ bytes bigint,
863
+ sha256 bytea,
864
+ error text,
865
+ created_at timestamptz NOT NULL DEFAULT now(),
866
+ finished_at timestamptz,
867
+ expires_at timestamptz NOT NULL -- artefact deleted after this; default +24 h
868
+ );
869
+ CREATE INDEX export_jobs_project_idx ON export_jobs (project_id, created_at DESC);
870
+ CREATE INDEX export_jobs_expiry_idx ON export_jobs (expires_at);
871
+
872
+ -- v2, shipped by the v2 migration with endpoint 41. Declared here because
873
+ -- [01](01-product.md) §6.4 renders reactions and reported that 05 carried
874
+ -- neither a table nor a route; the route is endpoint 41 and this is the table.
875
+ CREATE TABLE reactions (
876
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
877
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
878
+ comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
879
+ reviewer_id uuid NOT NULL REFERENCES reviewers(id) ON DELETE CASCADE,
880
+ emoji text NOT NULL CHECK (char_length(emoji) <= 16),
881
+ created_at timestamptz NOT NULL DEFAULT now(),
882
+ PRIMARY KEY (comment_id, reviewer_id, emoji)
883
+ );
884
+ CREATE INDEX reactions_comment_idx ON reactions (comment_id);
885
+
886
+ CREATE TABLE rate_counters ( -- durable tier only (§9.2). Org-scoped by §3.0.
887
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
888
+ bucket_key text NOT NULL,
889
+ window_start timestamptz NOT NULL,
890
+ count bigint NOT NULL DEFAULT 0,
891
+ expires_at timestamptz NOT NULL,
892
+ PRIMARY KEY (org_id, bucket_key)
893
+ );
894
+ CREATE INDEX rate_counters_expiry ON rate_counters (expires_at);
895
+
896
+ -- "Someone pasted your UID elsewhere" AND "we saw your widget on an origin you
897
+ -- have not allowed — add it?" are the same fact, so they are one table
898
+ -- (foundations §14.2b deletes the separate origin_suggestions). CAP 5 PER
899
+ -- PROJECT, which also bounds what a forged-Origin flood can write (§4.9).
900
+ CREATE TABLE origin_violations (
901
+ org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
902
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
903
+ origin text NOT NULL,
904
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
905
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
906
+ hits bigint NOT NULL DEFAULT 1,
907
+ -- false = it only ever fetched /config (the "add this origin?" suggestion case)
908
+ -- true = it attempted a write (the "your UID is in use elsewhere" case)
909
+ write_attempted boolean NOT NULL DEFAULT false,
910
+ notified_at timestamptz,
911
+ PRIMARY KEY (project_id, origin)
912
+ );
913
+
914
+ CREATE INDEX threads_hot ON threads (project_id, environment_id, route, status, updated_at DESC)
915
+ WHERE deleted_at IS NULL AND moderation = 'visible';
916
+
917
+ -- The client-role hot path (§6.4a). A partial index rather than widening
918
+ -- threads_hot with a boolean: `private` is overwhelmingly false, so the partial
919
+ -- index is smaller than the base one and the predicate is free at plan time.
920
+ CREATE INDEX threads_hot_public ON threads (project_id, environment_id, route, status, updated_at DESC)
921
+ WHERE deleted_at IS NULL AND moderation = 'visible' AND private = false;
922
+
923
+ CREATE INDEX threads_sync ON threads (project_id, environment_id, updated_at DESC, id DESC)
924
+ WHERE deleted_at IS NULL;
925
+
926
+ -- Delta polling for a CLIENT session: the same window, public rows only. A
927
+ -- client's `since` poll must never touch a private row, including to discover
928
+ -- that its updated_at moved.
929
+ CREATE INDEX threads_sync_public ON threads (project_id, environment_id, updated_at DESC, id DESC)
930
+ WHERE deleted_at IS NULL AND private = false;
931
+
932
+ -- Comments under the visibility predicate (§6.4a). comments_thread stays for
933
+ -- the studio read; this one serves the client read without a filter step.
934
+ CREATE INDEX comments_thread_public ON comments (thread_id, seq)
935
+ WHERE deleted_at IS NULL AND private = false;
936
+
937
+ CREATE INDEX threads_env_recent ON threads (project_id, environment_id, created_at DESC)
938
+ WHERE deleted_at IS NULL;
939
+
940
+ CREATE INDEX threads_anchor_testid ON threads (project_id, (anchor->>'testId'))
941
+ WHERE deleted_at IS NULL AND anchor ? 'testId';
942
+
943
+ CREATE INDEX threads_anchor_domid ON threads (project_id, (anchor->>'domId'))
944
+ WHERE deleted_at IS NULL AND anchor ? 'domId';
945
+
946
+ CREATE INDEX threads_orphans ON threads (project_id, environment_id, updated_at DESC)
947
+ WHERE deleted_at IS NULL AND anchor_state = 'orphaned';
948
+
949
+ CREATE INDEX threads_author ON threads (created_by, created_at DESC);
950
+ CREATE INDEX comments_author ON comments (author_id, created_at DESC);
951
+
952
+ CREATE INDEX comments_thread ON comments (thread_id, seq)
953
+ WHERE deleted_at IS NULL;
954
+
955
+ CREATE INDEX comments_dupe ON comments (author_id, body_hash, created_at DESC);
956
+
957
+
958
+ -- ── Row-level security ───────────────────────────────────────────────────
959
+ --
960
+ -- 08 §9.2: defence in depth BEHIND the application's own scoping, possible
961
+ -- only because the tenant key was there from the first migration rather
962
+ -- than retrofitted. A32's smoke test is the acceptance criterion: with
963
+ -- app.org_id set to org A, a query for org B's threads returns zero rows.
964
+
965
+ ALTER TABLE orgs ENABLE ROW LEVEL SECURITY;
966
+ ALTER TABLE orgs FORCE ROW LEVEL SECURITY;
967
+ CREATE POLICY orgs_org ON orgs USING (id = current_setting('app.org_id', true)::uuid);
968
+ ALTER TABLE users ENABLE ROW LEVEL SECURITY;
969
+ ALTER TABLE users FORCE ROW LEVEL SECURITY;
970
+ CREATE POLICY users_org ON users USING (id IN (SELECT user_id FROM memberships WHERE org_id = current_setting('app.org_id', true)::uuid));
971
+ ALTER TABLE user_identities ENABLE ROW LEVEL SECURITY;
972
+ ALTER TABLE user_identities FORCE ROW LEVEL SECURITY;
973
+ CREATE POLICY user_identities_org ON user_identities USING (org_id = current_setting('app.org_id', true)::uuid);
974
+ ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
975
+ ALTER TABLE memberships FORCE ROW LEVEL SECURITY;
976
+ CREATE POLICY memberships_org ON memberships USING (org_id = current_setting('app.org_id', true)::uuid);
977
+ ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
978
+ ALTER TABLE user_sessions FORCE ROW LEVEL SECURITY;
979
+ CREATE POLICY user_sessions_org ON user_sessions USING (org_id = current_setting('app.org_id', true)::uuid);
980
+ -- setup_tokens: no RLS. It is pre-tenant (05 §3.2 — "the ONE table with
981
+ -- no org_id, because it exists precisely when no org does"), so there is
982
+ -- nothing to scope by. Its safety is the token hash and single use.
983
+ ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
984
+ ALTER TABLE projects FORCE ROW LEVEL SECURITY;
985
+ CREATE POLICY projects_org ON projects USING (org_id = current_setting('app.org_id', true)::uuid);
986
+ ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
987
+ ALTER TABLE api_keys FORCE ROW LEVEL SECURITY;
988
+ CREATE POLICY api_keys_org ON api_keys USING (org_id = current_setting('app.org_id', true)::uuid);
989
+ ALTER TABLE mcp_tokens ENABLE ROW LEVEL SECURITY;
990
+ ALTER TABLE mcp_tokens FORCE ROW LEVEL SECURITY;
991
+ CREATE POLICY mcp_tokens_org ON mcp_tokens USING (org_id = current_setting('app.org_id', true)::uuid);
992
+ ALTER TABLE project_origins ENABLE ROW LEVEL SECURITY;
993
+ ALTER TABLE project_origins FORCE ROW LEVEL SECURITY;
994
+ CREATE POLICY project_origins_org ON project_origins USING (org_id = current_setting('app.org_id', true)::uuid);
995
+ ALTER TABLE environments ENABLE ROW LEVEL SECURITY;
996
+ ALTER TABLE environments FORCE ROW LEVEL SECURITY;
997
+ CREATE POLICY environments_org ON environments USING (org_id = current_setting('app.org_id', true)::uuid);
998
+ ALTER TABLE rounds ENABLE ROW LEVEL SECURITY;
999
+ ALTER TABLE rounds FORCE ROW LEVEL SECURITY;
1000
+ CREATE POLICY rounds_org ON rounds USING (org_id = current_setting('app.org_id', true)::uuid);
1001
+ ALTER TABLE review_links ENABLE ROW LEVEL SECURITY;
1002
+ ALTER TABLE review_links FORCE ROW LEVEL SECURITY;
1003
+ CREATE POLICY review_links_org ON review_links USING (org_id = current_setting('app.org_id', true)::uuid);
1004
+ ALTER TABLE reviewers ENABLE ROW LEVEL SECURITY;
1005
+ ALTER TABLE reviewers FORCE ROW LEVEL SECURITY;
1006
+ CREATE POLICY reviewers_org ON reviewers USING (org_id = current_setting('app.org_id', true)::uuid);
1007
+ ALTER TABLE threads ENABLE ROW LEVEL SECURITY;
1008
+ ALTER TABLE threads FORCE ROW LEVEL SECURITY;
1009
+ CREATE POLICY threads_org ON threads USING (org_id = current_setting('app.org_id', true)::uuid);
1010
+ ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
1011
+ ALTER TABLE comments FORCE ROW LEVEL SECURITY;
1012
+ CREATE POLICY comments_org ON comments USING (org_id = current_setting('app.org_id', true)::uuid);
1013
+ ALTER TABLE attachments ENABLE ROW LEVEL SECURITY;
1014
+ ALTER TABLE attachments FORCE ROW LEVEL SECURITY;
1015
+ CREATE POLICY attachments_org ON attachments USING (org_id = current_setting('app.org_id', true)::uuid);
1016
+ ALTER TABLE attachment_shares ENABLE ROW LEVEL SECURITY;
1017
+ ALTER TABLE attachment_shares FORCE ROW LEVEL SECURITY;
1018
+ CREATE POLICY attachment_shares_org ON attachment_shares USING (org_id = current_setting('app.org_id', true)::uuid);
1019
+ ALTER TABLE integrations ENABLE ROW LEVEL SECURITY;
1020
+ ALTER TABLE integrations FORCE ROW LEVEL SECURITY;
1021
+ CREATE POLICY integrations_org ON integrations USING (org_id = current_setting('app.org_id', true)::uuid);
1022
+ ALTER TABLE external_refs ENABLE ROW LEVEL SECURITY;
1023
+ ALTER TABLE external_refs FORCE ROW LEVEL SECURITY;
1024
+ CREATE POLICY external_refs_org ON external_refs USING (org_id = current_setting('app.org_id', true)::uuid);
1025
+ ALTER TABLE outbox ENABLE ROW LEVEL SECURITY;
1026
+ ALTER TABLE outbox FORCE ROW LEVEL SECURITY;
1027
+ CREATE POLICY outbox_org ON outbox USING (org_id = current_setting('app.org_id', true)::uuid);
1028
+ ALTER TABLE sync_ledger ENABLE ROW LEVEL SECURITY;
1029
+ ALTER TABLE sync_ledger FORCE ROW LEVEL SECURITY;
1030
+ CREATE POLICY sync_ledger_org ON sync_ledger USING (org_id = current_setting('app.org_id', true)::uuid);
1031
+ ALTER TABLE webhook_endpoints ENABLE ROW LEVEL SECURITY;
1032
+ ALTER TABLE webhook_endpoints FORCE ROW LEVEL SECURITY;
1033
+ CREATE POLICY webhook_endpoints_org ON webhook_endpoints USING (org_id = current_setting('app.org_id', true)::uuid);
1034
+ ALTER TABLE events ENABLE ROW LEVEL SECURITY;
1035
+ ALTER TABLE events FORCE ROW LEVEL SECURITY;
1036
+ CREATE POLICY events_org ON events USING (org_id = current_setting('app.org_id', true)::uuid);
1037
+ ALTER TABLE webhook_deliveries ENABLE ROW LEVEL SECURITY;
1038
+ ALTER TABLE webhook_deliveries FORCE ROW LEVEL SECURITY;
1039
+ CREATE POLICY webhook_deliveries_org ON webhook_deliveries USING (org_id = current_setting('app.org_id', true)::uuid);
1040
+ ALTER TABLE anchor_events ENABLE ROW LEVEL SECURITY;
1041
+ ALTER TABLE anchor_events FORCE ROW LEVEL SECURITY;
1042
+ CREATE POLICY anchor_events_org ON anchor_events USING (org_id = current_setting('app.org_id', true)::uuid);
1043
+ ALTER TABLE idempotency_keys ENABLE ROW LEVEL SECURITY;
1044
+ ALTER TABLE idempotency_keys FORCE ROW LEVEL SECURITY;
1045
+ CREATE POLICY idempotency_keys_org ON idempotency_keys USING (org_id = current_setting('app.org_id', true)::uuid);
1046
+ ALTER TABLE identity_nonces ENABLE ROW LEVEL SECURITY;
1047
+ ALTER TABLE identity_nonces FORCE ROW LEVEL SECURITY;
1048
+ CREATE POLICY identity_nonces_org ON identity_nonces USING (org_id = current_setting('app.org_id', true)::uuid);
1049
+ ALTER TABLE export_jobs ENABLE ROW LEVEL SECURITY;
1050
+ ALTER TABLE export_jobs FORCE ROW LEVEL SECURITY;
1051
+ CREATE POLICY export_jobs_org ON export_jobs USING (org_id = current_setting('app.org_id', true)::uuid);
1052
+ ALTER TABLE reactions ENABLE ROW LEVEL SECURITY;
1053
+ ALTER TABLE reactions FORCE ROW LEVEL SECURITY;
1054
+ CREATE POLICY reactions_org ON reactions USING (org_id = current_setting('app.org_id', true)::uuid);
1055
+ ALTER TABLE rate_counters ENABLE ROW LEVEL SECURITY;
1056
+ ALTER TABLE rate_counters FORCE ROW LEVEL SECURITY;
1057
+ CREATE POLICY rate_counters_org ON rate_counters USING (org_id = current_setting('app.org_id', true)::uuid);
1058
+ ALTER TABLE origin_violations ENABLE ROW LEVEL SECURITY;
1059
+ ALTER TABLE origin_violations FORCE ROW LEVEL SECURITY;
1060
+ CREATE POLICY origin_violations_org ON origin_violations USING (org_id = current_setting('app.org_id', true)::uuid);