artifacty 0.10.8 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,977 @@
1
+ # Artifacty Enhancement Roadmap Design
2
+
3
+ This document specifies the design for the next set of Artifacty capabilities.
4
+ Each feature is written so it can be implemented and tested independently, but
5
+ the features are grouped by theme and ordered by recommended priority. Every
6
+ feature follows the existing architecture rules: shared behavior lives in
7
+ `src/lib/*`, the HTTP, CLI, and MCP surfaces stay thin adapters, versions stay
8
+ append-only, artifact content stays untrusted, and no new runtime dependency is
9
+ added unless the section says so explicitly.
10
+
11
+ Implementation status: all sections below are implemented as of 2026-09-04; `STORE_VERSION` is now `8`. The original planning text follows.
12
+
13
+ Store schema changes bump `STORE_VERSION` (originally `4`) and use the existing
14
+ `ensureColumn` / `CREATE TABLE IF NOT EXISTS` migration path so older stores
15
+ upgrade on first access.
16
+
17
+ ## Contents
18
+
19
+ 1. [Cross-Cutting Conventions](#1-cross-cutting-conventions)
20
+ 2. [Artifact Relations](#2-artifact-relations)
21
+ 3. [Change Notifications](#3-change-notifications)
22
+ 4. [Optimistic Concurrency](#4-optimistic-concurrency)
23
+ 5. [Comments and Review Threads](#5-comments-and-review-threads)
24
+ 6. [Semantic Search](#6-semantic-search)
25
+ 7. [SARIF and CSV Sort, Filter, Download](#7-sarif-and-csv-sort-filter-download)
26
+ 8. [Dashboard Filters and Saved Views](#8-dashboard-filters-and-saved-views)
27
+ 9. [Retention Policies](#9-retention-policies)
28
+ 10. [Artifact Visibility and Ownership](#10-artifact-visibility-and-ownership)
29
+ 11. [API Token Scopes](#11-api-token-scopes)
30
+ 12. [Rate Limiting](#12-rate-limiting)
31
+ 13. [Full Backup Bundles](#13-full-backup-bundles)
32
+ 14. [Markdown Embedded Rendering](#14-markdown-embedded-rendering)
33
+ 15. [Jupyter Notebook Format](#15-jupyter-notebook-format)
34
+ 16. [Structured Diff](#16-structured-diff)
35
+ 17. [Document Assets in Bundles](#17-document-assets-in-bundles)
36
+ 18. [CLI Watch and Diff Commands](#18-cli-watch-and-diff-commands)
37
+ 19. [OpenAPI Specification](#19-openapi-specification)
38
+ 20. [MCP Protocol Refresh](#20-mcp-protocol-refresh)
39
+ 21. [Delivery Plan](#21-delivery-plan)
40
+
41
+ ---
42
+
43
+ ## 1. Cross-Cutting Conventions
44
+
45
+ ### 1.1 Module placement
46
+
47
+ | Concern | Module |
48
+ | --- | --- |
49
+ | Relations, comments, retention, visibility, scopes | `src/lib/storage.js` (or split into `src/lib/storage/*.js` if the file passes ~3000 lines) |
50
+ | Event bus and SSE/webhook fan-out | `src/lib/events.js` (new) |
51
+ | Embeddings | `src/lib/embeddings.js` (new) |
52
+ | Structured diff | `src/lib/diff.js` (extend) |
53
+ | Rate limiting | `src/lib/security.js` (extend) |
54
+ | OpenAPI document | `src/lib/openapi.js` (new) |
55
+ | Notebook conversion | `src/lib/converters.js` (extend) |
56
+
57
+ ### 1.2 Audit actions
58
+
59
+ Every new mutation writes an audit row through `insertAuditRecord`. New action
60
+ names introduced in this document:
61
+
62
+ `relation-add`, `relation-remove`, `comment-add`, `comment-resolve`,
63
+ `comment-delete`, `webhook-create`, `webhook-delete`, `webhook-deliver-failed`,
64
+ `retention-archive`, `retention-purge`, `visibility-change`, `owner-change`,
65
+ `token-scope-denied`, `update-conflict`, `rate-limited`.
66
+
67
+ ### 1.3 Error shape
68
+
69
+ HTTP JSON errors keep the existing `{ error: string }` body and add an optional
70
+ machine-readable `code`:
71
+
72
+ ```json
73
+ { "error": "Version conflict", "code": "version_conflict", "details": { "latestVersion": 4 } }
74
+ ```
75
+
76
+ MCP tools map the same `code` into `isError: true` results with the code in
77
+ `structuredContent.code`.
78
+
79
+ ### 1.4 Feature flags
80
+
81
+ New behavior that changes defaults for existing installs is gated by an
82
+ environment variable listed in each section, and every flag is reported by
83
+ `artifacty doctor` and `artifacty_info`.
84
+
85
+ ### 1.5 Testing
86
+
87
+ Each feature adds tests to the matching suite (`storage.test.js`,
88
+ `server.test.js`, `mcp-server.test.js`, `cli.test.js`, `converters.test.js`) and,
89
+ where behavior is externally visible, a smoke assertion in `npm run smoke`.
90
+
91
+ ---
92
+
93
+ ## 2. Artifact Relations
94
+
95
+ ### Problem
96
+
97
+ Artifacts are linked only through shared tags. A handoff, its review, and the
98
+ resulting release notes cannot be discovered from one another without knowing
99
+ the tag scheme used by the publishing agent.
100
+
101
+ ### Goals
102
+
103
+ - Typed, directional links between artifacts.
104
+ - Discoverable from list, get, MCP resources, and the browser viewer.
105
+ - Links survive archive and restore; a dangling link is reported, not deleted.
106
+
107
+ ### Data model
108
+
109
+ New table:
110
+
111
+ ```sql
112
+ CREATE TABLE IF NOT EXISTS artifact_relations (
113
+ id TEXT PRIMARY KEY,
114
+ from_id TEXT NOT NULL,
115
+ to_id TEXT NOT NULL,
116
+ relation TEXT NOT NULL,
117
+ created_at TEXT NOT NULL,
118
+ created_by TEXT,
119
+ metadata_json TEXT NOT NULL DEFAULT '{}',
120
+ UNIQUE (from_id, to_id, relation),
121
+ FOREIGN KEY (from_id) REFERENCES artifacts(id) ON DELETE CASCADE,
122
+ FOREIGN KEY (to_id) REFERENCES artifacts(id) ON DELETE CASCADE
123
+ );
124
+ CREATE INDEX IF NOT EXISTS idx_relations_from ON artifact_relations(from_id);
125
+ CREATE INDEX IF NOT EXISTS idx_relations_to ON artifact_relations(to_id);
126
+ ```
127
+
128
+ Relation vocabulary (validated, closed set for v1):
129
+
130
+ | Relation | Meaning |
131
+ | --- | --- |
132
+ | `derived-from` | `from` was produced by reading `to` |
133
+ | `supersedes` | `from` replaces `to` |
134
+ | `reviews` | `from` is a review of `to` |
135
+ | `references` | loose citation |
136
+ | `part-of` | `from` belongs to bundle/collection `to` |
137
+
138
+ Inverse names are computed, not stored (`derived-from` ↔ `derives`,
139
+ `supersedes` ↔ `superseded-by`, `reviews` ↔ `reviewed-by`, `part-of` ↔
140
+ `contains`, `references` ↔ `referenced-by`).
141
+
142
+ ### Storage API
143
+
144
+ ```js
145
+ addRelation(store, { fromId, toId, relation, audit, metadata })
146
+ removeRelation(store, { fromId, toId, relation, audit })
147
+ listRelations(store, id, { direction: "out" | "in" | "both", relation })
148
+ ```
149
+
150
+ `createArtifact` and `updateArtifact` accept an optional `relations` array
151
+ (`[{ toId, relation }]`) so an agent can link in one call. `getArtifact`
152
+ returns `relations: { outgoing: [...], incoming: [...] }` with each entry
153
+ carrying `toArtifactSummary` of the other side plus `missing: true` when the
154
+ target row no longer exists.
155
+
156
+ ### Surfaces
157
+
158
+ | Surface | Change |
159
+ | --- | --- |
160
+ | HTTP | `GET /api/artifacts/:id/relations`, `POST /api/artifacts/:id/relations` (body `{ toId, relation }`), `DELETE /api/artifacts/:id/relations/:relationId`. `GET /api/artifacts` accepts `relatedTo=<id>` and `relation=<name>`. |
161
+ | MCP | `artifacty_link`, `artifacty_unlink` tools; `artifacty_get` output includes `relations`; `artifacty_list` accepts `relatedTo`. New resource `artifacty://artifacts/{id}/graph` returning a depth-2 adjacency list. |
162
+ | CLI | `artifacty link <from> <relation> <to>`, `artifacty unlink ...`, `artifacty show --relations`. |
163
+ | Browser | Viewer sidebar "Related" panel; edit form gets a relation picker with search. |
164
+
165
+ ### Handoff prompt integration
166
+
167
+ The `artifacty_handoff` and `artifacty_review` prompt templates instruct the
168
+ agent to pass `relations: [{ toId: <source>, relation: "derived-from" }]` so the
169
+ graph is populated without user intervention.
170
+
171
+ ### Tests
172
+
173
+ Round trip, uniqueness constraint, cascade on artifact delete, dangling
174
+ reporting after admin version delete does not touch relations, `relatedTo`
175
+ filter with pagination, MCP tool schema exposure.
176
+
177
+ ---
178
+
179
+ ## 3. Change Notifications
180
+
181
+ ### Problem
182
+
183
+ Agents that wait for another agent's output must poll `artifacty_list`.
184
+ There is no push channel.
185
+
186
+ ### Goals
187
+
188
+ - In-process event bus that every mutation publishes to.
189
+ - Browser and CLI consumers via Server-Sent Events.
190
+ - External consumers via signed webhooks.
191
+ - MCP consumers via `notifications/resources/updated` when the transport
192
+ supports it (`/mcp` HTTP transport and the stdio bridge).
193
+
194
+ ### Non-goals
195
+
196
+ - Durable delivery guarantees beyond bounded retry.
197
+ - Cross-process fan-out between two server processes sharing one store.
198
+
199
+ ### Event model
200
+
201
+ ```json
202
+ {
203
+ "id": "evt_01J...",
204
+ "type": "artifact.updated",
205
+ "createdAt": "2026-09-04T10:00:00.000Z",
206
+ "artifactId": "release-handoff-abc12345",
207
+ "version": 3,
208
+ "actor": "user@example.com",
209
+ "sourceAgent": "claude",
210
+ "surface": "mcp",
211
+ "tags": ["handoff"],
212
+ "artifactType": "handoff"
213
+ }
214
+ ```
215
+
216
+ Event types: `artifact.created`, `artifact.updated`, `artifact.archived`,
217
+ `artifact.restored`, `artifact.relation.added`, `artifact.comment.added`,
218
+ `artifact.version.repaired`, `artifact.version.deleted`.
219
+
220
+ Events are derived from audit rows. `insertAuditRecord` becomes the single
221
+ publish point: after the transaction commits, the storage layer calls
222
+ `events.publish(eventFromAudit(row))`. Events are never published for
223
+ uncommitted transactions.
224
+
225
+ ### Persistence
226
+
227
+ Events are stored for replay in a bounded table so a reconnecting SSE client
228
+ can resume with `Last-Event-ID`:
229
+
230
+ ```sql
231
+ CREATE TABLE IF NOT EXISTS events (
232
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
233
+ id TEXT NOT NULL UNIQUE,
234
+ created_at TEXT NOT NULL,
235
+ type TEXT NOT NULL,
236
+ artifact_id TEXT,
237
+ payload_json TEXT NOT NULL
238
+ );
239
+ ```
240
+
241
+ Retention: keep the newest `ARTIFACTY_EVENT_HISTORY` rows (default 10000);
242
+ prune on insert.
243
+
244
+ ### SSE endpoint
245
+
246
+ `GET /api/events` with `Accept: text/event-stream`.
247
+
248
+ Query filters: `type`, `tag`, `artifactId`, `sourceAgent`. Auth uses the same
249
+ token rules as other API routes. Heartbeat comment every 25 seconds. On
250
+ connect with `Last-Event-ID`, replay from that sequence. Max concurrent
251
+ connections per server: `ARTIFACTY_SSE_MAX_CLIENTS` (default 64); excess
252
+ connections get `503`.
253
+
254
+ ### Webhooks
255
+
256
+ ```sql
257
+ CREATE TABLE IF NOT EXISTS webhooks (
258
+ id TEXT PRIMARY KEY,
259
+ url TEXT NOT NULL,
260
+ secret_hash TEXT NOT NULL,
261
+ event_types_json TEXT NOT NULL,
262
+ filter_json TEXT NOT NULL DEFAULT '{}',
263
+ owner_user_id TEXT,
264
+ created_at TEXT NOT NULL,
265
+ disabled_at TEXT,
266
+ last_delivery_at TEXT,
267
+ last_status INTEGER,
268
+ failure_count INTEGER NOT NULL DEFAULT 0
269
+ );
270
+ ```
271
+
272
+ - Delivery: `POST` JSON body, headers `X-Artifacty-Event`,
273
+ `X-Artifacty-Delivery`, `X-Artifacty-Signature: sha256=<hmac>` over the raw
274
+ body using the secret shown once at creation.
275
+ - Retry: 3 attempts with 2s, 10s, 60s backoff. After 20 consecutive failures the
276
+ webhook is disabled and a `webhook-deliver-failed` audit row is written.
277
+ - SSRF guard: the target URL must be `http` or `https`; loopback, link-local,
278
+ and private ranges are rejected unless `ARTIFACTY_WEBHOOK_ALLOW_PRIVATE=true`.
279
+ Redirects are not followed.
280
+ - Admin only for creation in team mode; the token owner in single-user mode.
281
+
282
+ Routes: `GET/POST /api/webhooks`, `DELETE /api/webhooks/:id`,
283
+ `POST /api/webhooks/:id/test`. Browser page `/admin/webhooks`.
284
+
285
+ ### MCP
286
+
287
+ - `initialize` advertises `resources: { subscribe: true, listChanged: true }`.
288
+ - `resources/subscribe` on `artifacty://artifacts/{id}` or `artifacty://recent`
289
+ registers the session; matching events send
290
+ `notifications/resources/updated` with the URI.
291
+ - The stdio bridge forwards notifications received from the remote `/mcp`
292
+ stream to the local client.
293
+ - New tool `artifacty_wait` with `{ artifactId?, tag?, type?, timeoutMs }`
294
+ blocks up to `timeoutMs` (max 120000) and returns the first matching event or
295
+ `{ timedOut: true }`. This gives clients without subscription support a
296
+ long-poll primitive.
297
+
298
+ ### CLI
299
+
300
+ See section 18 for `artifacty watch`.
301
+
302
+ ### Tests
303
+
304
+ Event ordering matches audit sequence, replay by `Last-Event-ID`, filter
305
+ matching, webhook signature verification, SSRF rejection, retry then disable,
306
+ MCP subscribe round trip through the HTTP transport, `artifacty_wait` timeout.
307
+
308
+ ---
309
+
310
+ ## 4. Optimistic Concurrency
311
+
312
+ ### Problem
313
+
314
+ Two agents that both read version 3 and both call `artifacty_update` produce
315
+ versions 4 and 5. The second write silently discards the first agent's work
316
+ from the "latest" view.
317
+
318
+ ### Design
319
+
320
+ - Every artifact response exposes `latestVersion` (already present) and an
321
+ `etag` string equal to `"<id>:<latestVersion>"`.
322
+ - `updateArtifact` accepts `expectedVersion` (number). Inside the existing
323
+ transaction, if `artifact.latestVersion !== expectedVersion`, throw
324
+ `VersionConflictError` carrying `latestVersion`, and write an
325
+ `update-conflict` audit row.
326
+ - HTTP: `POST /api/artifacts/:id/versions` (and the browser edit form) accept
327
+ `If-Match: "<etag>"` or body `expectedVersion`. Conflict returns `409` with
328
+ `code: "version_conflict"` and the current summary so the client can rebase.
329
+ `GET /api/artifacts/:id` returns `ETag` and honors `If-None-Match` with
330
+ `304`.
331
+ - MCP: `artifacty_update` gains optional `expectedVersion`. The
332
+ `artifacty_get` output already carries `latestVersion`; the tool description
333
+ tells agents to pass it back.
334
+ - CLI: `artifacty update --expected-version N`.
335
+ - Browser editor: hidden field with the version being edited; on `409` the
336
+ editor shows a banner with a link to the diff between the edited base and the
337
+ new latest version, and keeps the unsaved text.
338
+
339
+ `expectedVersion` is optional so existing clients keep working. A future
340
+ `ARTIFACTY_REQUIRE_EXPECTED_VERSION=true` flag can make it mandatory for API
341
+ and MCP writes.
342
+
343
+ ### Tests
344
+
345
+ Conflict raised inside the transaction (no version file written), success path
346
+ with matching version, `If-Match` header parsing including weak validators,
347
+ `304` on `If-None-Match`, browser banner rendering, MCP conflict result shape.
348
+
349
+ ---
350
+
351
+ ## 5. Comments and Review Threads
352
+
353
+ ### Problem
354
+
355
+ Feedback on an artifact today requires publishing a whole new version or a
356
+ separate review artifact. Lightweight, version-anchored notes are missing.
357
+
358
+ ### Data model
359
+
360
+ ```sql
361
+ CREATE TABLE IF NOT EXISTS artifact_comments (
362
+ id TEXT PRIMARY KEY,
363
+ artifact_id TEXT NOT NULL,
364
+ version INTEGER NOT NULL,
365
+ parent_id TEXT,
366
+ author_user_id TEXT,
367
+ author_label TEXT NOT NULL,
368
+ source_agent TEXT,
369
+ body TEXT NOT NULL,
370
+ anchor_json TEXT,
371
+ status TEXT NOT NULL DEFAULT 'open',
372
+ created_at TEXT NOT NULL,
373
+ resolved_at TEXT,
374
+ resolved_by TEXT,
375
+ deleted_at TEXT,
376
+ FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE
377
+ );
378
+ CREATE INDEX IF NOT EXISTS idx_comments_artifact ON artifact_comments(artifact_id, version);
379
+ ```
380
+
381
+ - `anchor_json` is optional and format-specific: `{ "line": 42 }` for text
382
+ formats, `{ "path": "$.runs[0].results[3]" }` for JSON/SARIF, `{ "row": 7 }`
383
+ for CSV. Anchors are hints for rendering and are not validated against
384
+ content.
385
+ - `body` is Markdown, rendered through the same sanitized Markdown pipeline as
386
+ artifact content and size-capped at 16 KB.
387
+ - Comments are soft-deleted; the audit log keeps the action.
388
+ - Threads are one level deep (`parent_id` points to a root comment).
389
+
390
+ ### Review state
391
+
392
+ An artifact-level `reviewStatus` column (`none`, `pending`, `changes-requested`,
393
+ `approved`) is added to `artifacts`. It is set explicitly via
394
+ `POST /api/artifacts/:id/review-status` and reset to `pending` automatically
395
+ when a new version is appended after an approval, which is recorded in the
396
+ audit metadata. This is deliberately minimal: no multi-approver rules.
397
+
398
+ ### Surfaces
399
+
400
+ | Surface | Change |
401
+ | --- | --- |
402
+ | HTTP | `GET/POST /api/artifacts/:id/comments`, `POST /api/artifacts/:id/comments/:cid/resolve`, `DELETE /api/artifacts/:id/comments/:cid`, `POST /api/artifacts/:id/review-status`. |
403
+ | MCP | `artifacty_comment` `{ id, version?, body, anchor?, parentId? }`, `artifacty_resolve_comment`, `artifacty_set_review_status`. `artifacty_get` gains `includeComments` (default false, returns open comments on the requested version). |
404
+ | CLI | `artifacty comment <id> --body ...`, `artifacty comments <id>`. |
405
+ | Browser | Viewer side panel listing comments by version with resolve/reply; line-anchored comments show gutter markers in the CodeMirror read-only viewer. |
406
+ | Events | `artifact.comment.added`, `artifact.review_status.changed`. |
407
+
408
+ The `artifacty_review` prompt template is updated to prefer comments over
409
+ publishing a separate review artifact when the review is short.
410
+
411
+ ### Tests
412
+
413
+ Thread depth limit, anchor pass-through, soft delete hides from list but audit
414
+ remains, review status reset on new version, MCP tool round trip, Markdown
415
+ sanitization of comment bodies.
416
+
417
+ ---
418
+
419
+ ## 6. Semantic Search
420
+
421
+ ### Problem
422
+
423
+ FTS5 handles keyword queries. Natural-language questions such as "the analysis
424
+ of last week's failed deploy" miss when the wording differs.
425
+
426
+ ### Design principles
427
+
428
+ - No mandatory dependency and no bundled model. Embeddings come from a
429
+ pluggable provider.
430
+ - Semantic search is additive: when disabled or unavailable, behavior is
431
+ unchanged.
432
+ - Vectors are stored in SQLite as BLOBs; similarity is computed in JavaScript.
433
+ This is adequate for the expected store size (tens of thousands of artifacts)
434
+ and avoids a native vector extension.
435
+
436
+ ### Provider interface (`src/lib/embeddings.js`)
437
+
438
+ ```js
439
+ export function createEmbeddingProvider(config) // returns null when disabled
440
+ provider.name // "openai-compatible" | "command" | "none"
441
+ provider.dimensions // integer
442
+ provider.embed(texts) // Promise<Float32Array[]>
443
+ ```
444
+
445
+ Providers in v1:
446
+
447
+ | Provider | Config |
448
+ | --- | --- |
449
+ | `openai-compatible` | `ARTIFACTY_EMBEDDINGS_URL`, `ARTIFACTY_EMBEDDINGS_MODEL`, `ARTIFACTY_EMBEDDINGS_API_KEY` (calls `POST {url}/embeddings`) |
450
+ | `command` | `ARTIFACTY_EMBEDDINGS_COMMAND` (a local executable that reads JSON lines on stdin and writes vectors on stdout, so users can wire Ollama or any local model without Artifacty depending on it) |
451
+
452
+ The API key is read from the environment only and never written to the store
453
+ or logs.
454
+
455
+ ### Storage
456
+
457
+ ```sql
458
+ CREATE TABLE IF NOT EXISTS artifact_embeddings (
459
+ artifact_id TEXT NOT NULL,
460
+ version INTEGER NOT NULL,
461
+ provider TEXT NOT NULL,
462
+ model TEXT NOT NULL,
463
+ dimensions INTEGER NOT NULL,
464
+ vector BLOB NOT NULL,
465
+ created_at TEXT NOT NULL,
466
+ PRIMARY KEY (artifact_id, provider, model),
467
+ FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE
468
+ );
469
+ ```
470
+
471
+ The embedded text is `title + tags + metadata summary + first
472
+ ARTIFACTY_EMBEDDINGS_MAX_CHARS (default 8000) characters of the latest version`.
473
+ Binary formats (`image`, `video`) embed metadata only.
474
+
475
+ Indexing runs through the existing background module (`src/lib/background.js`)
476
+ after each create/update so writes never wait on a network call. Failures are
477
+ logged and retried by `artifacty index rebuild --embeddings`.
478
+
479
+ ### Query
480
+
481
+ `GET /api/artifacts?q=...&mode=semantic|keyword|hybrid`.
482
+
483
+ - `keyword`: current FTS5 path.
484
+ - `semantic`: embed the query, cosine similarity over all vectors for the
485
+ configured provider/model, top `limit` after `offset`.
486
+ - `hybrid` (default when a provider is configured): reciprocal rank fusion of
487
+ keyword and semantic rankings, `k = 60`.
488
+
489
+ Responses add `search.mode` and per-row `search_score`. `artifacty_list` gets
490
+ the same `mode` argument. The dashboard exposes a mode toggle only when
491
+ `artifacty_info` reports a provider.
492
+
493
+ ### Tests
494
+
495
+ Provider-less path unchanged, `command` provider with a fixture script,
496
+ cosine ordering, RRF merge determinism, rebuild command, secret redaction of
497
+ the API key in doctor output.
498
+
499
+ ---
500
+
501
+ ## 7. SARIF and CSV Sort, Filter, Download
502
+
503
+ Completes the "Future Extensions" list in `docs/sarif-csv-artifact-plan.md`.
504
+
505
+ ### Design
506
+
507
+ - Rendering stays server-side and bounded; interactivity is progressive
508
+ enhancement in `src/client/viewer.js` using data already in the table.
509
+ - SARIF viewer: level filter chips (`error`, `warning`, `note`), rule id text
510
+ filter, sort by level, rule, or location. Filtering operates on the bounded
511
+ set already rendered; a notice states when results were truncated.
512
+ - CSV viewer: click column header to sort; per-column contains filter; row
513
+ count shown.
514
+ - Download: `GET /artifacts/:id/export?format=csv&filter=...&sort=...` for CSV,
515
+ and `?format=sarif&level=error` for SARIF. The server re-parses the stored
516
+ original, applies the filter, and streams a fresh file. `/raw` is untouched.
517
+ Filtered exports are capped at `MAX_ARTIFACT_BYTES`.
518
+ - Real-world fixtures for CodeQL, Semgrep, and Trivy are added under
519
+ `test/fixtures/sarif/` and used in converter and server tests.
520
+
521
+ ### Tests
522
+
523
+ Fixture parsing, filter/sort parameter validation, export content type and
524
+ byte cap, viewer script has no inline event handlers (CSP compatibility).
525
+
526
+ ---
527
+
528
+ ## 8. Dashboard Filters and Saved Views
529
+
530
+ ### Design
531
+
532
+ - List filters extend to `artifactType`, `publisher`, `createdAfter`,
533
+ `createdBefore`, `reviewStatus`, `relatedTo`, and `mode` (search). All are
534
+ query-string driven so URLs remain shareable and the CLI/MCP list surfaces
535
+ reuse the same `listArtifactsPage` filters.
536
+ - Saved views:
537
+
538
+ ```sql
539
+ CREATE TABLE IF NOT EXISTS saved_views (
540
+ id TEXT PRIMARY KEY,
541
+ owner_user_id TEXT,
542
+ name TEXT NOT NULL,
543
+ filters_json TEXT NOT NULL,
544
+ shared INTEGER NOT NULL DEFAULT 0,
545
+ created_at TEXT NOT NULL,
546
+ updated_at TEXT NOT NULL
547
+ );
548
+ ```
549
+
550
+ In single-user mode (no users table rows) views are global; in team mode they
551
+ belong to a user and can be marked `shared` so they appear for everyone.
552
+
553
+ - Routes: `GET/POST /api/views`, `DELETE /api/views/:id`; browser sidebar
554
+ lists views and a "Save current filters" action.
555
+ - MCP: `artifacty_list` accepts `view: "<name or id>"` which expands to the
556
+ saved filters, so a prompt can say "list the `open-reviews` view".
557
+ - Dashboard grouping: optional `groupBy=artifactType|sourceAgent|day` renders
558
+ section headers; purely presentational.
559
+
560
+ ### Tests
561
+
562
+ Filter parsing edge cases (invalid dates), view ownership and sharing
563
+ visibility, MCP view expansion, i18n keys present for `en` and `ko`.
564
+
565
+ ---
566
+
567
+ ## 9. Retention Policies
568
+
569
+ ### Problem
570
+
571
+ Artifacts, audit rows, and (after section 3) events grow without bound.
572
+
573
+ ### Design
574
+
575
+ Policies are declarative and evaluated by a background sweep in
576
+ `src/lib/background.js` every `ARTIFACTY_RETENTION_INTERVAL` (default 1h) and
577
+ on demand via `artifacty retention run`.
578
+
579
+ Configuration lives in the `meta` table as JSON under key `retention_policy`
580
+ and is edited through `/admin/retention` or `artifacty retention set`:
581
+
582
+ ```json
583
+ {
584
+ "archiveAfterDays": { "default": null, "byType": { "test-report": 30 } },
585
+ "purgeArchivedAfterDays": 180,
586
+ "auditRetentionDays": 365,
587
+ "eventRetentionRows": 10000,
588
+ "keepTags": ["pinned", "release"]
589
+ }
590
+ ```
591
+
592
+ Semantics:
593
+
594
+ - `archiveAfterDays`: artifacts not updated within the window are archived
595
+ (`retention-archive` audit action, actor `system:retention`). Artifacts with
596
+ a tag in `keepTags` or with `reviewStatus = approved` are skipped.
597
+ - `purgeArchivedAfterDays`: archived artifacts older than the window are
598
+ hard-deleted, including version files, relations, comments, and embeddings.
599
+ This is the only path that deletes whole artifacts, and it always runs with
600
+ a dry-run report first (`artifacty retention run --dry-run`) that admins can
601
+ inspect on `/admin/retention`. Purge is disabled unless
602
+ `ARTIFACTY_RETENTION_ALLOW_PURGE=true`.
603
+ - `auditRetentionDays`: audit rows older than the window are deleted, except
604
+ `version-repair`, `version-delete`, `retention-purge`, and
605
+ `owner-change` which are kept indefinitely.
606
+ - Every sweep writes a summary row (`retention-sweep`) so operators can see
607
+ when it ran and what it touched.
608
+
609
+ `artifacty integrity` gains a check that no orphaned files remain after a
610
+ purge.
611
+
612
+ ### Tests
613
+
614
+ Policy parsing and defaults, `keepTags` exemption, dry-run produces no
615
+ changes, purge removes files and dependent rows, audit exemptions, sweep
616
+ idempotency.
617
+
618
+ ---
619
+
620
+ ## 10. Artifact Visibility and Ownership
621
+
622
+ ### Problem
623
+
624
+ In team mode every user sees and can update every artifact. There are only two
625
+ roles.
626
+
627
+ ### Data model
628
+
629
+ New columns on `artifacts`:
630
+
631
+ - `visibility TEXT NOT NULL DEFAULT 'team'` with values `private`, `team`.
632
+ - `owner_user_id TEXT` backfilled from `publisher_user_id` on migration.
633
+
634
+ Optional teams are out of scope for v1; `team` means "all authenticated users
635
+ of this server". A later version can add a `groups` table without changing
636
+ the API shape.
637
+
638
+ ### Rules
639
+
640
+ | Action | `private` | `team` |
641
+ | --- | --- | --- |
642
+ | Read, list, raw, diff | owner, admin | any authenticated user |
643
+ | Update, comment, link | owner, admin | any authenticated user unless `ARTIFACTY_TEAM_WRITE=owner` |
644
+ | Archive, restore, change visibility, change owner | owner, admin | owner, admin |
645
+ | Admin version repair/delete | admin | admin |
646
+
647
+ - In single-user mode (no users) everything is allowed as today.
648
+ - Anonymous access with a shared `ARTIFACTY_API_TOKEN` but no personal token
649
+ is treated as `team` read/write and cannot see `private` artifacts.
650
+ - List queries add a `WHERE visibility = 'team' OR owner_user_id = ?` clause;
651
+ FTS and semantic search apply the same predicate before ranking.
652
+ - `createArtifact` accepts `visibility`; MCP `artifacty_create`/`update`
653
+ expose it with a description that defaults to `team`.
654
+ - `POST /api/artifacts/:id/visibility` and `POST /api/artifacts/:id/owner`
655
+ (admin or owner) write `visibility-change` / `owner-change` audit rows.
656
+ - Backup export includes both columns; import of an older bundle defaults them.
657
+
658
+ ### Browser
659
+
660
+ Viewer shows a visibility badge; the edit form offers the toggle to owners and
661
+ admins; the account page lists "My private artifacts".
662
+
663
+ ### Tests
664
+
665
+ Predicate applied on every read path including `/raw`, relation listing hides
666
+ private targets from non-owners (returns `restricted: true` instead of the
667
+ summary), MCP resource read denial, migration backfill of `owner_user_id`.
668
+
669
+ ---
670
+
671
+ ## 11. API Token Scopes
672
+
673
+ ### Design
674
+
675
+ `api_tokens` gains `scopes_json TEXT NOT NULL DEFAULT '["read","write"]'`.
676
+
677
+ Scopes:
678
+
679
+ | Scope | Grants |
680
+ | --- | --- |
681
+ | `read` | list, get, raw, diff, relations, comments (read), events (SSE) |
682
+ | `write` | create, import, update, link, comment, archive/restore of own artifacts |
683
+ | `admin` | everything the user role allows; only available to admin users |
684
+
685
+ - `authenticateApiToken` returns `scopes`; a new `requireScope(auth, scope)`
686
+ helper in `src/lib/security.js` is called by each HTTP route and MCP tool
687
+ handler. Denials return `403` with `code: "scope_denied"` and write a
688
+ `token-scope-denied` audit row (rate-limited to one per token per minute to
689
+ avoid log flooding).
690
+ - MCP `tools/list` filters out mutating tools when the authenticated token
691
+ lacks `write`, so read-only agents never see tools they cannot call.
692
+ - `/account` token creation form adds scope checkboxes; `artifacty token`
693
+ CLI (server-issued personal tokens) gains `--scope read`.
694
+ - Existing tokens keep full scopes through the column default.
695
+
696
+ ### Tests
697
+
698
+ Scope enforcement per route, MCP tool list filtering, admin scope requires
699
+ admin role, default for legacy tokens.
700
+
701
+ ---
702
+
703
+ ## 12. Rate Limiting
704
+
705
+ ### Design
706
+
707
+ A fixed-window counter in memory keyed by `(principal, bucket)` where principal
708
+ is the token id, user id, or remote address, in that order of preference.
709
+
710
+ | Bucket | Default limit | Env |
711
+ | --- | --- | --- |
712
+ | `write` (create, import, update, comment, link) | 120 / minute | `ARTIFACTY_RATE_WRITE_PER_MIN` |
713
+ | `auth` (login, token exchange) | 10 / minute per address | `ARTIFACTY_RATE_AUTH_PER_MIN` |
714
+ | `search` | 300 / minute | `ARTIFACTY_RATE_SEARCH_PER_MIN` |
715
+
716
+ - Disabled on loopback binds unless `ARTIFACTY_RATE_LIMIT=always`.
717
+ - Responses over the limit return `429` with `Retry-After` and
718
+ `code: "rate_limited"`. MCP tools return the same as an error result.
719
+ - Body size limits already exist (`MAX_ARTIFACT_BYTES`, `MAX_BACKUP_BYTES`);
720
+ this section adds `MAX_COMMENT_BYTES` (16 KB) and documents all limits in
721
+ `docs/threat-model.md`.
722
+
723
+ ### Tests
724
+
725
+ Window reset, principal selection order, loopback bypass, `Retry-After`
726
+ header.
727
+
728
+ ---
729
+
730
+ ## 13. Full Backup Bundles
731
+
732
+ ### Problem
733
+
734
+ The current bundle excludes users, sessions, tokens, and audit logs, so a
735
+ server move needs manual steps.
736
+
737
+ ### Design
738
+
739
+ - `buildStoreBackup(store, { scope: "artifacts" | "full" })`. `full` adds
740
+ `users` (with password hashes), `api_tokens` (hashes and scopes),
741
+ `audit_log`, `artifact_relations`, `artifact_comments`, `saved_views`,
742
+ `webhooks` (without secrets; they must be re-issued), and `meta` policy
743
+ keys. Sessions are never exported.
744
+ - Bundle header gains `bundleVersion: 2`, `scope`, and `storeVersion`.
745
+ Version 1 bundles import as before.
746
+ - Import of a `full` bundle is admin-only, requires
747
+ `confirm: "replace-all"` in the request body, and refuses when the target
748
+ store already has users unless `--force-users` is passed. It runs in one
749
+ transaction and writes a `backup-import` audit row summarizing counts.
750
+ - CLI: `artifacty backup --full`, `artifacty import-store --file x.json`
751
+ auto-detects scope; `artifacty export` keeps the artifacts-only default.
752
+ - Bundles containing password or token hashes are written with mode `0600`.
753
+
754
+ ### Tests
755
+
756
+ Round trip of every table, v1 bundle compatibility, refusal conditions,
757
+ sessions absent, file mode.
758
+
759
+ ---
760
+
761
+ ## 14. Markdown Embedded Rendering
762
+
763
+ ### Design
764
+
765
+ - Fenced code blocks in Markdown artifacts get syntax highlighting using the
766
+ already-vendored CodeMirror language packages in read-only mode, applied
767
+ client-side by `viewer.js` to `<pre><code class="language-x">` elements.
768
+ Server output remains plain escaped HTML so no-JS and CSP-strict contexts
769
+ still work.
770
+ - ```` ```mermaid ```` fences render through the existing sandboxed Mermaid
771
+ iframe path, one iframe per diagram, lazily created when scrolled into view.
772
+ A per-document cap (`ARTIFACTY_MAX_INLINE_DIAGRAMS`, default 20) prevents
773
+ resource exhaustion.
774
+ - Task lists (`- [ ]`) render as disabled checkboxes; tables get horizontal
775
+ scroll containers.
776
+ - No change to stored content or to `/raw`.
777
+
778
+ ### Tests
779
+
780
+ Highlight class assignment, Mermaid fence extraction, cap enforcement,
781
+ sanitization unchanged for inline HTML in Markdown.
782
+
783
+ ---
784
+
785
+ ## 15. Jupyter Notebook Format
786
+
787
+ ### Design
788
+
789
+ - New format `notebook` (`.ipynb`, `application/x-ipynb+json`), default
790
+ artifact type `analysis-report`.
791
+ - Import detection: JSON object with `nbformat` and `cells[]`.
792
+ - Converter produces a normalized structure for rendering only; the stored
793
+ content is the original notebook JSON.
794
+ - Viewer renders cells in order: Markdown cells through the Markdown pipeline,
795
+ code cells as highlighted source, outputs limited to `text/plain`,
796
+ `text/markdown`, `image/png`, `image/jpeg`, `image/svg+xml` (sanitized, in
797
+ the scriptless SVG iframe). `text/html` outputs render inside the sandboxed
798
+ HTML iframe like HTML artifacts. Other MIME types show a placeholder with
799
+ the type name.
800
+ - Size guard: outputs larger than 2 MB are replaced with a "truncated" notice.
801
+ - `artifacty import --agent generic --file notebook.ipynb` and the browser
802
+ import page detect it automatically.
803
+
804
+ ### Tests
805
+
806
+ Detection, cell rendering order, output MIME allowlist, oversized output
807
+ truncation, `/raw` fidelity.
808
+
809
+ ---
810
+
811
+ ## 16. Structured Diff
812
+
813
+ ### Design
814
+
815
+ `src/lib/diff.js` gains:
816
+
817
+ ```js
818
+ createStructuredDiff(before, after, { format })
819
+ ```
820
+
821
+ | Format | Strategy |
822
+ | --- | --- |
823
+ | `json`, `sarif`, `notebook` | Recursive object diff producing `added`, `removed`, `changed` entries keyed by JSON path; arrays of objects with an `id`, `ruleId`, or `path` key are matched by that key, otherwise by index. |
824
+ | `csv` | Header-aware row diff: rows matched by the first column when it is unique, otherwise by position; reports added, removed, changed cells. |
825
+ | `markdown`, `text`, `code`, `html` | Existing line diff plus word-level highlighting inside changed lines. |
826
+ | `bundle` | Per-file diff using the strategy of each file's format. |
827
+
828
+ - `/artifacts/:id/diff?from=1&to=2&view=structured|lines` picks the renderer;
829
+ `structured` is default for JSON-like formats.
830
+ - `GET /api/artifacts/:id/diff?from&to` returns the diff as JSON.
831
+ - MCP: `artifacty_diff` tool `{ id, from, to, view }` returns both a unified
832
+ text rendering in `content[].text` and the structure in `structuredContent`.
833
+ - Output is capped at `ARTIFACTY_MAX_DIFF_ENTRIES` (default 5000) with a
834
+ truncation flag.
835
+
836
+ ### Tests
837
+
838
+ Key-matched array diff, positional fallback, CSV header change, word-level
839
+ highlight escaping, cap flag, MCP tool shape.
840
+
841
+ ---
842
+
843
+ ## 17. Document Assets in Bundles
844
+
845
+ ### Design
846
+
847
+ - Bundle file entries gain optional `contentType`. Allowed binary types are
848
+ extended with `application/pdf`,
849
+ `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
850
+ `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, and
851
+ `application/zip`.
852
+ - Viewer: PDFs render in a sandboxed iframe via `/raw?file=<name>` with
853
+ `Content-Disposition: inline`; other document types show metadata and a
854
+ download link with `Content-Disposition: attachment` and `X-Content-Type-
855
+ Options: nosniff`.
856
+ - Per-file cap 32 MB and total bundle cap remains `MAX_ARTIFACT_BYTES`.
857
+ - Secret scanning is skipped for binary entries but file names are still
858
+ checked.
859
+
860
+ ### Tests
861
+
862
+ Type allowlist, disposition headers, size caps, download route auth.
863
+
864
+ ---
865
+
866
+ ## 18. CLI Watch and Diff Commands
867
+
868
+ ### `artifacty watch`
869
+
870
+ ```bash
871
+ artifacty watch --tag handoff --type artifact.updated --json
872
+ artifacty watch --artifact release-handoff-abc12345 --exec "./on-change.sh"
873
+ ```
874
+
875
+ - Connects to `/api/events` using the URL from `server.json` or
876
+ `ARTIFACTY_URL`, reconnects with `Last-Event-ID` on drop.
877
+ - Prints one JSON line per event with `--json`, or a human line otherwise.
878
+ - `--exec` runs the command with the event JSON on stdin and `ARTIFACTY_EVENT_*`
879
+ environment variables; failures are printed but do not stop the watch.
880
+ - `--once` exits after the first match (a shell-friendly `artifacty_wait`).
881
+
882
+ ### `artifacty diff`
883
+
884
+ ```bash
885
+ artifacty diff <id> [--from N] [--to M] [--structured] [--json]
886
+ ```
887
+
888
+ Defaults `--to` to latest and `--from` to `to - 1`. Uses section 16.
889
+
890
+ ### `artifacty relations`, `artifacty comment`, `artifacty retention`, `artifacty views`
891
+
892
+ Thin wrappers over the storage APIs described in their sections, following the
893
+ existing `cli.js` command table pattern and JSON output conventions.
894
+
895
+ ### Tests
896
+
897
+ Argument parsing, reconnect logic with a mocked SSE server, `--once` exit
898
+ code, `--exec` environment injection.
899
+
900
+ ---
901
+
902
+ ## 19. OpenAPI Specification
903
+
904
+ ### Design
905
+
906
+ - `src/lib/openapi.js` builds an OpenAPI 3.1 document from a single route
907
+ table that `server.js` also uses for dispatch, so the spec cannot drift
908
+ from the implementation. The route table entry shape:
909
+
910
+ ```js
911
+ { method: "POST", path: "/api/artifacts", handler, auth: "write", summary, requestSchema, responseSchema }
912
+ ```
913
+
914
+ - Served at `GET /openapi.json` (no auth) and rendered as a static reference
915
+ page at `/docs/api` using server-side HTML (no external UI bundle).
916
+ - JSON Schemas reuse `ARTIFACT_FORMATS`, `ARTIFACT_TYPES`, and the MCP tool
917
+ input schemas so one definition feeds both HTTP and MCP.
918
+ - A test asserts every registered route appears in the spec and every spec
919
+ path has a handler.
920
+ - `docs/mcp-public-api.md` links to the spec; `artifacty_info` reports the
921
+ URL.
922
+
923
+ ---
924
+
925
+ ## 20. MCP Protocol Refresh
926
+
927
+ ### Design
928
+
929
+ - Re-verify the hand-rolled implementation against the newest MCP
930
+ specification before implementing sections 3 and 5. The checklist:
931
+ - Negotiate the newest protocol version while continuing to accept
932
+ `2025-06-18` from older clients.
933
+ - `tools/list` `outputSchema` for every tool that returns
934
+ `structuredContent`, generated from the same schema table as section 19.
935
+ - `resources/subscribe`, `listChanged` notifications (section 3).
936
+ - Elicitation: if the client advertises `elicitation`, `artifacty_update`
937
+ without `expectedVersion` on a conflict may ask the user whether to rebase;
938
+ otherwise return the conflict error.
939
+ - Pagination cursors on `tools/list`, `resources/list`, and
940
+ `prompts/list` when result counts exceed 50.
941
+ - Streamable HTTP session resumption on `/mcp` (`Mcp-Session-Id`, event
942
+ replay) reusing the events table.
943
+ - `artifacty check` validates the negotiated version and capability set and
944
+ fails when a client requires a capability the server does not report.
945
+ - `docs/mcp-public-api.md` gains a compatibility matrix by protocol version.
946
+
947
+ ### Tests
948
+
949
+ Version negotiation with old and new clients, `outputSchema` presence,
950
+ subscribe flow, cursor pagination, session resume through the HTTP transport.
951
+
952
+ ---
953
+
954
+ ## 21. Delivery Plan
955
+
956
+ | Phase | Features | Store version | Rationale |
957
+ | --- | --- | --- | --- |
958
+ | 1 | 4 Optimistic concurrency, 2 Relations, 20 MCP refresh (schema table), 19 OpenAPI | 5 | Smallest changes with the largest effect on multi-agent correctness; the shared schema table unblocks later work. |
959
+ | 2 | 3 Notifications, 18 CLI watch/diff, 16 Structured diff | 6 | Push channel and diff make "continue from another agent's output" real. |
960
+ | 3 | 10 Visibility, 11 Token scopes, 12 Rate limiting, 13 Full backups | 7 | Team-mode hardening before wider LAN use. |
961
+ | 4 | 5 Comments, 8 Filters and views, 9 Retention | 8 | Day-to-day usability and long-running store health. |
962
+ | 5 | 6 Semantic search, 14 Markdown rendering, 15 Notebook, 7 SARIF/CSV, 17 Document assets | 8 (no bump; new tables are created idempotently) | Content and discovery improvements that build on everything above. |
963
+
964
+ Each phase ends with `npm run release:check`, an update to `README.md`,
965
+ `docs/mcp-public-api.md`, `docs/threat-model.md`, and `docs/artifact-schema-v1.md`
966
+ (or a `v2` schema document when the artifact envelope changes), and a
967
+ `STORE_VERSION` bump only when a table or column was added in that phase.
968
+
969
+ ### Compatibility guarantees across all phases
970
+
971
+ - Existing HTTP responses keep their current top-level keys; new keys are
972
+ additive.
973
+ - `artifacty_publish` remains an alias of `artifacty_create`.
974
+ - Stores without users keep single-user semantics for every new permission
975
+ check.
976
+ - `/raw` always returns the stored bytes unchanged.
977
+ - No generated MCP config hard-codes `http://127.0.0.1:8787`.