@openparachute/vault 0.6.5-rc.9 → 0.7.0-rc.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 (58) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/contract-concurrency.test.ts +117 -0
  3. package/core/src/contract-taxonomy.test.ts +119 -0
  4. package/core/src/contract-typed-index.test.ts +106 -0
  5. package/core/src/core.test.ts +7 -7
  6. package/core/src/mcp.ts +1 -1
  7. package/core/src/schema.ts +5 -5
  8. package/core/src/seed-packs.test.ts +222 -56
  9. package/core/src/seed-packs.ts +334 -70
  10. package/core/src/tag-hierarchy.ts +1 -1
  11. package/core/src/tag-schemas.ts +2 -2
  12. package/core/src/types.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/admin-spa.ts +2 -1
  15. package/src/auth.ts +1 -1
  16. package/src/cli.ts +317 -53
  17. package/src/contract-errors.test.ts +98 -0
  18. package/src/contract-honest-queries.test.ts +100 -0
  19. package/src/contract-search.test.ts +127 -0
  20. package/src/export-watch.ts +1 -1
  21. package/src/live-frame-parity.test.ts +201 -0
  22. package/src/module-manifest.ts +8 -0
  23. package/src/onboarding-seed.test.ts +82 -20
  24. package/src/onboarding-seed.ts +2 -2
  25. package/src/routes.ts +3 -3
  26. package/src/routing.test.ts +2 -2
  27. package/src/routing.ts +18 -6
  28. package/src/self-register.test.ts +19 -0
  29. package/src/self-register.ts +6 -1
  30. package/src/server.ts +56 -0
  31. package/src/services-manifest.ts +8 -0
  32. package/src/subscriptions.ts +100 -42
  33. package/src/tag-scope.ts +3 -3
  34. package/src/test-support/live-frame-corpus.ts +72 -0
  35. package/src/token-store.ts +2 -2
  36. package/src/transcription/build.test.ts +86 -5
  37. package/src/transcription/build.ts +87 -7
  38. package/src/transcription/capability.test.ts +25 -0
  39. package/src/transcription/capability.ts +27 -2
  40. package/src/transcription/download.test.ts +101 -0
  41. package/src/transcription/download.ts +71 -0
  42. package/src/transcription/install-python.test.ts +366 -0
  43. package/src/transcription/install-python.ts +471 -0
  44. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  45. package/src/transcription/providers/onnx-asr.ts +239 -0
  46. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  47. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  48. package/src/transcription/select.test.ts +166 -1
  49. package/src/transcription/select.ts +234 -1
  50. package/src/transcription/tiers.test.ts +197 -0
  51. package/src/transcription/tiers.ts +184 -0
  52. package/src/vault-create.test.ts +19 -14
  53. package/src/vault.test.ts +1 -1
  54. package/src/ws-server.ts +408 -0
  55. package/src/ws-subscribe.test.ts +474 -0
  56. package/src/ws-subscribe.ts +242 -0
  57. package/src/subscribe.test.ts +0 -609
  58. package/src/subscribe.ts +0 -248
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Contract suite — honest queries at the REST boundary (Wave 1 of the
3
+ * Reliability & Usability Program, umbrella #556). Encodes the 2026-07-09
4
+ * nine-persona deep test's WS1 findings (#550) as executable tests: PASSING
5
+ * tests lock in behavior that is correct today; `test.todo` entries describe
6
+ * the target behavior for confirmed-broken cases, to be flipped to real
7
+ * assertions in a later wave. See #550 for the full write-up — this file
8
+ * covers the `src/routes.ts` REST surface specifically (the cursor-bootstrap
9
+ * gap lives at routes.ts:1102/1106, the tags/{name} 404 gap at the GET /tags
10
+ * handler in `handleTags`).
11
+ */
12
+
13
+ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { BunStore } from "./vault-store.ts";
16
+ import { handleNotes, handleTags } from "./routes.ts";
17
+
18
+ let db: Database;
19
+ let store: BunStore;
20
+
21
+ const BASE = "http://localhost/api";
22
+
23
+ function getNotes(qs: string): Promise<Response> {
24
+ return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
25
+ }
26
+
27
+ function getTags(qs: string): Promise<Response> {
28
+ return handleTags(new Request(`${BASE}/tags?${qs}`, { method: "GET" }), store, "");
29
+ }
30
+
31
+ beforeEach(() => {
32
+ db = new Database(":memory:");
33
+ store = new BunStore(db);
34
+ });
35
+
36
+ afterEach(() => {
37
+ db.close();
38
+ });
39
+
40
+ describe("contract: honest queries — passing (lock in current behavior)", () => {
41
+ it("querying a nonexistent tag returns 200 with an empty array, not an error", async () => {
42
+ const res = await getNotes("tag=zzznonexistenttag");
43
+ expect(res.status).toBe(200);
44
+ const body = await res.json();
45
+ // Shape may gain a `warnings` field later (#550) — assert only status +
46
+ // array shape now so that addition doesn't break this contract test.
47
+ expect(Array.isArray(body)).toBe(true);
48
+ expect(body).toEqual([]);
49
+ });
50
+
51
+ it("a metadata operator query on a non-indexed field errors loudly with FIELD_NOT_INDEXED (existing behavior)", async () => {
52
+ const res = await getNotes("meta[not_a_real_field][gt]=5");
53
+ expect(res.status).toBe(400);
54
+ const body: any = await res.json();
55
+ expect(body.code).toBe("FIELD_NOT_INDEXED");
56
+ });
57
+
58
+ it("a `near` query against a nonexistent anchor note errors cleanly with 404, not a silent []", async () => {
59
+ const res = await getNotes("near[note_id]=does-not-exist");
60
+ expect(res.status).toBe(404);
61
+ const body: any = await res.json();
62
+ expect(body.error).toBeDefined();
63
+ expect(body.note_id).toBe("does-not-exist");
64
+ });
65
+
66
+ // removed in W2 — once #550's cursor-bootstrap fix lands, an omitted
67
+ // `cursor` param will (per the documented intent) still return a flat
68
+ // array for callers who never opt into cursor pagination, so this
69
+ // specific assertion should survive; the paired todo below is the one
70
+ // that flips when the bootstrap gap closes. Kept both here so the fix
71
+ // is a conscious, reviewed change rather than an incidental snapshot.
72
+ it("a cursor-less limit query returns a flat array (today's shape) — removed in W2", async () => {
73
+ await store.createNote("only note");
74
+ const res = await getNotes("limit=5");
75
+ expect(res.status).toBe(200);
76
+ const body = await res.json();
77
+ expect(Array.isArray(body)).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe("contract: honest queries — todo (#550)", () => {
82
+ test.todo(
83
+ "#550: limit=-1 returns a structured error instead of silently meaning \"unlimited\" (today: SQLite's negative-LIMIT-means-no-limit semantics leak straight through to the caller)",
84
+ );
85
+ test.todo(
86
+ "#550: query responses carry a warnings: [] channel, populated with a did_you_mean suggestion for an unknown tag name",
87
+ );
88
+ test.todo(
89
+ "#550: an invalid date_filter value (unparseable date string) returns a structured error instead of silently matching nothing or everything",
90
+ );
91
+ test.todo(
92
+ "#550: GET /api/tags/{nonexistent} returns 404 instead of 200 with an all-null synthesized record",
93
+ );
94
+ test.todo(
95
+ "#550: list-tags reports expanded_count (rollup through parent_names descendants) alongside the literal per-tag count — today a parent tag with only child-tagged notes reports count: 0",
96
+ );
97
+ test.todo(
98
+ "#550: cursor bootstrap — a first call that expresses cursor intent (no `cursor` param yet, but pagination is desired) returns a {notes, next_cursor} envelope, and a second call passing that cursor back sees only notes written since the first call (today: routes.ts only wraps the response in {notes, next_cursor} when a cursor param is ALREADY present, so the very first call can never obtain one — core/src/mcp.ts:331 documents a bootstrap flow that is not actually reachable)",
99
+ );
100
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Contract suite — search (Wave 1 of the Reliability & Usability Program,
3
+ * umbrella #556). Encodes the 2026-07-09 nine-persona deep test's search
4
+ * findings (WS2, #551) as executable tests: PASSING tests lock in behavior
5
+ * that is correct today; `test.todo` entries describe the target behavior
6
+ * for confirmed-broken cases, to be flipped to real assertions in a later
7
+ * wave. See #551 for the full write-up.
8
+ *
9
+ * Ground truth for every assertion here was re-verified live against this
10
+ * repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
11
+ * /notes?search=`) before writing — see the PR description for the probe
12
+ * transcript.
13
+ */
14
+
15
+ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
16
+ import { Database } from "bun:sqlite";
17
+ import { BunStore } from "./vault-store.ts";
18
+ import { handleNotes } from "./routes.ts";
19
+
20
+ let db: Database;
21
+ let store: BunStore;
22
+
23
+ const BASE = "http://localhost/api";
24
+
25
+ function search(qs: string): Promise<Response> {
26
+ return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
27
+ }
28
+
29
+ /** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
30
+ const NOTES = {
31
+ hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
32
+ contraction: "She said she didn't know about the capping delay.",
33
+ decimal: "The measurement came out to 18.6 percent this quarter.",
34
+ bothWords: "A plain keyword note about widgets and gadgets.",
35
+ widgetsOnly: "Only widgets here, no other word.",
36
+ gadgetsOnly: "Gadgets alone, nothing else notable.",
37
+ filler1: "Quarterly report drafted for the finance team.",
38
+ filler2: "Meeting notes from the Tuesday standup.",
39
+ filler3: "Grocery list: eggs, bread, oat milk.",
40
+ filler4: "Project plan for the Q3 roadmap.",
41
+ filler5: "Weekly retro: wins and blockers for the sprint.",
42
+ filler6: "Random thoughts on the trip itinerary.",
43
+ };
44
+
45
+ beforeEach(async () => {
46
+ db = new Database(":memory:");
47
+ store = new BunStore(db);
48
+ for (const content of Object.values(NOTES)) {
49
+ await store.createNote(content);
50
+ }
51
+ });
52
+
53
+ afterEach(() => {
54
+ db.close();
55
+ });
56
+
57
+ async function bodyOf(res: Response): Promise<any> {
58
+ return res.json();
59
+ }
60
+
61
+ describe("contract: search — passing (lock in current behavior)", () => {
62
+ it("plain keyword search finds the matching notes", async () => {
63
+ const res = await search("search=widgets&include_content=true");
64
+ expect(res.status).toBe(200);
65
+ const body = await bodyOf(res);
66
+ const contents = new Set(body.map((n: any) => n.content));
67
+ expect(contents.has(NOTES.bothWords)).toBe(true);
68
+ expect(contents.has(NOTES.widgetsOnly)).toBe(true);
69
+ expect(contents.has(NOTES.gadgetsOnly)).toBe(false);
70
+ });
71
+
72
+ it("two-word unquoted search is implicit AND — only the note containing BOTH terms matches", async () => {
73
+ const res = await search("search=widgets+gadgets&include_content=true");
74
+ expect(res.status).toBe(200);
75
+ const body = await bodyOf(res);
76
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
77
+ });
78
+
79
+ it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay"', async () => {
80
+ const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&include_content=true`);
81
+ expect(res.status).toBe(200);
82
+ const body = await bodyOf(res);
83
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
84
+ });
85
+
86
+ it('quoted decimal literal finds it: "18.6"', async () => {
87
+ const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
88
+ expect(res.status).toBe(200);
89
+ const body = await bodyOf(res);
90
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
91
+ });
92
+
93
+ it('quoted contraction finds it: "didn\'t"', async () => {
94
+ const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
95
+ expect(res.status).toBe(200);
96
+ const body = await bodyOf(res);
97
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
98
+ });
99
+
100
+ it("unknown-word search returns []", async () => {
101
+ const res = await search("search=zzzznonexistentword&include_content=true");
102
+ expect(res.status).toBe(200);
103
+ const body = await bodyOf(res);
104
+ expect(body).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe("contract: search — todo (#551, literal-by-default + recall + ranking)", () => {
109
+ test.todo(
110
+ `#551: unquoted search: "didn't" finds the contraction content (literal-by-default — today the bare apostrophe splits into two AND'd tokens and returns [])`,
111
+ );
112
+ test.todo(
113
+ `#551: unquoted search: "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — today the bare hyphenated word tokenizes into separate AND'd terms and returns [])`,
114
+ );
115
+ test.todo(
116
+ `#551: unquoted search: "18.6" finds the decimal note (literal-by-default — today the bare decimal tokenizes into separate AND'd terms and returns [])`,
117
+ );
118
+ test.todo(
119
+ `#551: search_mode:"advanced" preserves raw FTS5 query syntax (AND/OR/NOT, phrase, prefix) once literal-by-default escaping ships as the new default`,
120
+ );
121
+ test.todo(
122
+ `#551: sort:"asc"/"desc" changes result order under search (today silently ignored — FTS5 rank order wins regardless of the sort param)`,
123
+ );
124
+ test.todo(
125
+ `#551: malformed FTS syntax (e.g. an unbalanced quote) yields a structured warning or error, not a silently swallowed [] (today searchNotes catches every FTS5 syntax error and returns [])`,
126
+ );
127
+ });
@@ -10,7 +10,7 @@
10
10
  * cursor captured at the *start* of the previous cycle. Vault writes are
11
11
  * HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
12
12
  * is opaque), so polling on `updated_at >= cursor` is the simplest robust
13
- * detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
13
+ * detection. See `docs/contracts/vault-portable-export.md`.
14
14
  */
15
15
 
16
16
  import { ensureGitAvailable } from "./git-preflight.ts";
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Live-query frame PARITY + the pure WS helpers (WS-hibernation migration,
3
+ * self-host door).
4
+ *
5
+ * The load-bearing CROSS-DOOR conformance check: for the shared frame corpus
6
+ * (mirrored byte-for-byte from the cloud door), the self-host WS message folds
7
+ * the event name into a `type` discriminator (plus the snapshot `done` chunk
8
+ * flag) over an inner payload (`notes` / `note` / `id`) that serializes
9
+ * byte-IDENTICALLY to the corpus. Because the corpus AND the `wsFrame` formatter
10
+ * are byte-shaped identical to cloud's, this transitively proves: self-host WS
11
+ * bytes === corpus === cloud WS bytes. (SSE was the original transport; it was
12
+ * removed in Phase 5 — WebSocket is now the sole live transport, polling the
13
+ * client floor.)
14
+ *
15
+ * Plus unit coverage of the pure helpers that back the Bun.serve integration:
16
+ * snapshot chunking, first-message parsing, verb-rank, and the query rejects.
17
+ */
18
+ import { describe, it, expect } from "bun:test";
19
+ import { wsFrame, WsSink } from "./subscriptions.ts";
20
+ import {
21
+ buildSnapshotFrames,
22
+ parseClientMessage,
23
+ vaultVerbRank,
24
+ sameTagScope,
25
+ validateWsSubscribeQuery,
26
+ subscriptionCapResponse,
27
+ urlFromQuery,
28
+ SNAPSHOT_CHUNK_MAX_NOTES,
29
+ WS_CLOSE,
30
+ } from "./ws-subscribe.ts";
31
+ import { unsupportedSubscriptionReason } from "./live-match.ts";
32
+ import { FRAME_CORPUS, NOTE_A } from "./test-support/live-frame-corpus.ts";
33
+
34
+ describe("live-frame parity — self-host WS bytes === corpus === cloud WS bytes", () => {
35
+ for (const c of FRAME_CORPUS) {
36
+ it(`${c.name}: WS message folds the event into \`type\` over the corpus payload`, () => {
37
+ const ws = wsFrame(c.event, c.data);
38
+
39
+ // WS message is `{ type, ...data }` — the event name folds into `type`.
40
+ const parsedWs = JSON.parse(ws) as Record<string, unknown>;
41
+ expect(parsedWs.type).toBe(c.event);
42
+ const { type, ...rest } = parsedWs;
43
+ void type;
44
+ expect(rest).toEqual(c.data);
45
+
46
+ // BYTE parity of the inner payload: whatever value the event carries
47
+ // (`notes` / `note` / `id`) serializes IDENTICALLY to the corpus
48
+ // serialization (the cross-door invariant).
49
+ const innerKey = c.event === "snapshot" ? "notes" : c.event === "upsert" ? "note" : "id";
50
+ const innerBytes = JSON.stringify((c.data as Record<string, unknown>)[innerKey]);
51
+ expect(ws.includes(innerBytes)).toBe(true);
52
+ });
53
+ }
54
+ });
55
+
56
+ describe("sink classes render through the ONE formatter", () => {
57
+ it("WsSink.send emits exactly wsFrame(event, data)", () => {
58
+ const sent: string[] = [];
59
+ const sink = new WsSink({ send: (d: string) => sent.push(d), close: () => {} });
60
+ for (const c of FRAME_CORPUS) {
61
+ sink.send(c.event, c.data);
62
+ expect(sent.at(-1)).toBe(wsFrame(c.event, c.data));
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("buildSnapshotFrames — chunking + done flag", () => {
68
+ it("empty set → exactly one done:true frame", () => {
69
+ const frames = buildSnapshotFrames([]);
70
+ expect(frames.length).toBe(1);
71
+ expect(JSON.parse(frames[0]!)).toEqual({ type: "snapshot", notes: [], done: true });
72
+ });
73
+
74
+ it("small set → one done:true frame carrying every note", () => {
75
+ const frames = buildSnapshotFrames([NOTE_A as any, NOTE_A as any]);
76
+ expect(frames.length).toBe(1);
77
+ const f = JSON.parse(frames[0]!);
78
+ expect(f.done).toBe(true);
79
+ expect(f.notes.length).toBe(2);
80
+ });
81
+
82
+ it("> SNAPSHOT_CHUNK_MAX_NOTES → multiple frames, only the last done:true, concat = full set", () => {
83
+ const many = Array.from({ length: SNAPSHOT_CHUNK_MAX_NOTES + 5 }, (_, i) => ({ ...NOTE_A, id: `n-${i}` }));
84
+ const frames = buildSnapshotFrames(many as any);
85
+ expect(frames.length).toBeGreaterThan(1);
86
+ const parsed = frames.map((f) => JSON.parse(f));
87
+ expect(parsed.filter((p) => p.done).length).toBe(1);
88
+ expect(parsed[parsed.length - 1]!.done).toBe(true);
89
+ parsed.slice(0, -1).forEach((p) => expect(p.done).toBe(false));
90
+ const concat = parsed.flatMap((p) => p.notes);
91
+ expect(concat.map((n: any) => n.id)).toEqual(many.map((n) => n.id));
92
+ });
93
+
94
+ it("each frame stays under the WS message ceiling with large notes", () => {
95
+ const big = Array.from({ length: 8 }, (_, i) => ({ ...NOTE_A, id: `big-${i}`, content: "x".repeat(200_000) }));
96
+ const frames = buildSnapshotFrames(big as any);
97
+ expect(frames.length).toBeGreaterThan(1);
98
+ for (const f of frames) expect(new TextEncoder().encode(f).byteLength).toBeLessThan(1_000_000);
99
+ expect(frames.flatMap((f) => JSON.parse(f).notes).length).toBe(8);
100
+ });
101
+ });
102
+
103
+ describe("parseClientMessage", () => {
104
+ it("valid auth", () => {
105
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "t" }))).toEqual({ kind: "auth", token: "t" });
106
+ });
107
+ it("auth without a token → malformed", () => {
108
+ expect(parseClientMessage(JSON.stringify({ type: "auth" })).kind).toBe("malformed");
109
+ expect(parseClientMessage(JSON.stringify({ type: "auth", token: "" })).kind).toBe("malformed");
110
+ });
111
+ it("non-json / non-object → malformed", () => {
112
+ expect(parseClientMessage("not json").kind).toBe("malformed");
113
+ expect(parseClientMessage(JSON.stringify([1, 2])).kind).toBe("malformed");
114
+ });
115
+ it("other typed message → other", () => {
116
+ expect(parseClientMessage(JSON.stringify({ type: "hello" }))).toEqual({ kind: "other", type: "hello" });
117
+ });
118
+ it("decodes a Uint8Array frame", () => {
119
+ const bytes = new TextEncoder().encode(JSON.stringify({ type: "auth", token: "b" }));
120
+ expect(parseClientMessage(bytes)).toEqual({ kind: "auth", token: "b" });
121
+ });
122
+ });
123
+
124
+ describe("vaultVerbRank — narrow-or-equal ordering", () => {
125
+ it("ranks read < write < admin, -1 for none/other-vault", () => {
126
+ expect(vaultVerbRank(["vault:v:read"], "v")).toBe(0);
127
+ expect(vaultVerbRank(["vault:v:write"], "v")).toBe(1);
128
+ expect(vaultVerbRank(["vault:v:admin"], "v")).toBe(2);
129
+ expect(vaultVerbRank(["vault:other:read"], "v")).toBe(-1);
130
+ expect(vaultVerbRank([], "v")).toBe(-1);
131
+ });
132
+ it("a widen is strictly greater (the 4403 trigger)", () => {
133
+ expect(vaultVerbRank(["vault:v:admin"], "v") > vaultVerbRank(["vault:v:read"], "v")).toBe(true);
134
+ expect(vaultVerbRank(["vault:v:read"], "v") > vaultVerbRank(["vault:v:admin"], "v")).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("sameTagScope — the WS re-auth tag-scope-delta gate", () => {
139
+ it("both unscoped (null) → same", () => {
140
+ expect(sameTagScope(null, null)).toBe(true);
141
+ });
142
+ it("null vs scoped → differ (a drop/gain of scoping)", () => {
143
+ expect(sameTagScope(null, ["work/eng"])).toBe(false);
144
+ expect(sameTagScope(["work/eng"], null)).toBe(false);
145
+ });
146
+ it("same set (order/dupe-insensitive) → same", () => {
147
+ expect(sameTagScope(["a", "b"], ["b", "a"])).toBe(true);
148
+ expect(sameTagScope(["a", "a", "b"], ["b", "a"])).toBe(true);
149
+ });
150
+ it("different membership → differ", () => {
151
+ expect(sameTagScope(["work/eng"], ["work/sales"])).toBe(false);
152
+ expect(sameTagScope(["a"], ["a", "b"])).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("validateWsSubscribeQuery — same rejects as the SSE route (byte-identical bodies)", () => {
157
+ const reject = async (q: string) => {
158
+ const v = validateWsSubscribeQuery(new URL(`http://x/vault/v/api/subscribe?${q}`));
159
+ expect("error" in v).toBe(true);
160
+ const body = await (v as { error: Response }).error.json();
161
+ expect((v as { error: Response }).error.status).toBe(400);
162
+ expect(body.code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
163
+ };
164
+ it("rejects the URL-reachable unsupported shapes: search / near / cursor / has_links", async () => {
165
+ await reject("search=hi");
166
+ await reject("near[note_id]=abc");
167
+ await reject("cursor=xyz");
168
+ await reject("has_links=true");
169
+ });
170
+ it("accepts a supported query", () => {
171
+ const v = validateWsSubscribeQuery(new URL("http://x/vault/v/api/subscribe?tag=chat&path_prefix=meetings/"));
172
+ expect("queryOpts" in v).toBe(true);
173
+ });
174
+ it("shares the SSE route's queryOpts-level guard (cursor/has_links/date filters)", () => {
175
+ // The belt-and-suspenders layer both doors + the SSE route route through:
176
+ // a date filter isn't expressible as a flat URL param (removed 0.6.4), but
177
+ // if one ever reaches queryOpts it is rejected identically.
178
+ expect(unsupportedSubscriptionReason({ cursor: "x" } as any)).toBeTruthy();
179
+ expect(unsupportedSubscriptionReason({ hasLinks: true } as any)).toBeTruthy();
180
+ expect(unsupportedSubscriptionReason({ dateFrom: "2026-01-01" } as any)).toBeTruthy();
181
+ expect(unsupportedSubscriptionReason({ dateFilter: { field: "created_at" } } as any)).toBeTruthy();
182
+ expect(unsupportedSubscriptionReason({ tags: ["chat"] } as any)).toBeNull();
183
+ });
184
+ });
185
+
186
+ describe("misc pure helpers", () => {
187
+ it("subscriptionCapResponse → 503 SUBSCRIPTION_CAP_REACHED", async () => {
188
+ const res = subscriptionCapResponse();
189
+ expect(res.status).toBe(503);
190
+ expect((await res.json()).code).toBe("SUBSCRIPTION_CAP_REACHED");
191
+ });
192
+ it("urlFromQuery reconstructs searchParams from a raw query string", () => {
193
+ expect(urlFromQuery("?tag=chat&a=b").searchParams.get("tag")).toBe("chat");
194
+ expect(urlFromQuery("").search).toBe("");
195
+ });
196
+ it("WS_CLOSE carries the four application close codes", () => {
197
+ expect([WS_CLOSE.PROTOCOL, WS_CLOSE.UNAUTHORIZED, WS_CLOSE.FORBIDDEN, WS_CLOSE.AUTH_TIMEOUT]).toEqual([
198
+ 4400, 4401, 4403, 4408,
199
+ ]);
200
+ });
201
+ });
@@ -48,6 +48,14 @@ export interface VaultModuleManifest {
48
48
  readonly paths: readonly string[];
49
49
  readonly health: string;
50
50
  readonly stripPrefix?: boolean;
51
+ /**
52
+ * When `true`, vault's daemon accepts WebSocket upgrades (the live-query WS
53
+ * binding) and the hub's ws-bridge forwards `Upgrade: websocket` requests on
54
+ * vault's mounts. DENY BY DEFAULT on the hub side. Carried onto the
55
+ * self-registered services.json row too (`ServiceEntry.websocket`); the hub
56
+ * honors either source. See `parachute-hub/src/ws-bridge.ts`.
57
+ */
58
+ readonly websocket?: boolean;
51
59
  }
52
60
 
53
61
  /**
@@ -23,24 +23,29 @@ import { BunStore } from "./vault-store.ts";
23
23
  import { seedOnboardingNotes } from "./onboarding-seed.ts";
24
24
  import {
25
25
  applySeedPack,
26
+ CAPTURE_ANYTHING_PATH,
26
27
  CONNECT_AI_PATH,
27
28
  GETTING_STARTED_PATH,
28
29
  NOTES_REQUIRED_TAGS,
29
30
  SURFACE_STARTER_PACK,
30
31
  SURFACE_STARTER_PATH,
31
- TRY_LINKING_PATH,
32
+ TAGS_GRAPH_PATH,
32
33
  WELCOME_PATH,
34
+ YOURS_TO_KEEP_PATH,
33
35
  } from "../core/src/seed-packs.ts";
34
36
  import {
35
37
  buildVaultProjection,
36
38
  projectionToMarkdown,
37
39
  } from "../core/src/vault-projection.ts";
38
40
 
39
- /** Every note path the default seed writes on a blank vault. */
41
+ /** Every note path the default seed writes on a blank vault (the five-guide
42
+ * welcome ring + the AI-facing Getting Started). */
40
43
  const DEFAULT_SEED_PATHS = [
41
44
  WELCOME_PATH,
42
- TRY_LINKING_PATH,
45
+ CAPTURE_ANYTHING_PATH,
46
+ TAGS_GRAPH_PATH,
43
47
  CONNECT_AI_PATH,
48
+ YOURS_TO_KEEP_PATH,
44
49
  GETTING_STARTED_PATH,
45
50
  ];
46
51
 
@@ -77,18 +82,30 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
77
82
  expect(gs!.content).toContain("Tags = types");
78
83
  expect(gs!.content).toContain("Paths = organization");
79
84
  expect(gs!.content).toContain("Schemas = typed metadata fields");
80
- expect(gs!.content).toContain("parachute-vault import");
85
+ // Data-in leads with the import UI + MCP bridge; the old `parachute-vault
86
+ // import` CLI line (a live bug on cloud) is gone (2026-07-06 rewrite).
87
+ expect(gs!.content).not.toContain("parachute-vault import");
88
+ expect(gs!.content).toContain("Bringing existing notes in");
81
89
  expect(gs!.content).toContain("Adapt this note");
82
90
 
83
- // The welcome web: three person-voiced notes.
84
- for (const path of [WELCOME_PATH, TRY_LINKING_PATH, CONNECT_AI_PATH]) {
91
+ // The welcome ring: five person-voiced guide notes.
92
+ for (const path of [
93
+ WELCOME_PATH,
94
+ CAPTURE_ANYTHING_PATH,
95
+ TAGS_GRAPH_PATH,
96
+ CONNECT_AI_PATH,
97
+ YOURS_TO_KEEP_PATH,
98
+ ]) {
85
99
  expect(await store.getNoteByPath(path)).not.toBeNull();
86
100
  }
87
101
  });
88
102
 
89
- test("seeds the ONE capture tag Notes requires (byte-equal semantics)", async () => {
103
+ test("seeds the capture tag Notes requires (byte-equal semantics) + the guide tag", async () => {
90
104
  const result = await seedOnboardingNotes(store);
91
- expect(result.tags).toEqual(["capture"]);
105
+ // welcome upserts [capture, guide]; getting-started re-upserts [guide]
106
+ // (converges). applySeedPack reports every declared tag per pack, so the
107
+ // concatenation carries guide twice — deterministic.
108
+ expect(result.tags).toEqual(["capture", "guide", "guide"]);
92
109
 
93
110
  for (const decl of NOTES_REQUIRED_TAGS) {
94
111
  const record = await store.getTagRecord(decl.name);
@@ -97,6 +114,15 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
97
114
  expect(record!.parent_names ?? []).toEqual(decl.parent_names ?? []);
98
115
  }
99
116
 
117
+ // The guide tag lands with no metadata schema — guides are for you and your
118
+ // AI alike, plain markdown, so no per-guide audience field (written_for dropped).
119
+ const guide = await store.getTagRecord("guide");
120
+ expect(guide).not.toBeNull();
121
+ expect(guide!.fields?.written_for).toBeUndefined();
122
+ // #pinned is NOT seeded (dropped 2026-07-07 — did nothing visible on a fresh
123
+ // vault). Pinning still works when the user pins a note in the app.
124
+ expect(await store.getTagRecord("pinned")).toBeNull();
125
+
100
126
  // The retired subtype tags are NOT seeded — entry method is note
101
127
  // metadata.source (text|voice), not taxonomy (2026-07-03). Existing
102
128
  // vaults carrying these tags stay valid; new vaults don't get them.
@@ -104,6 +130,29 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
104
130
  expect(await store.getTagRecord("capture/voice")).toBeNull();
105
131
  });
106
132
 
133
+ test("seeded guides carry #guide (create-time tag write-through)", async () => {
134
+ await seedOnboardingNotes(store);
135
+
136
+ // Welcome guide: tagged guide (no longer pinned).
137
+ const welcome = await store.getNoteByPath(WELCOME_PATH);
138
+ expect([...(welcome!.tags ?? [])].sort()).toEqual(["guide"]);
139
+
140
+ // The four sibling guides: guide only.
141
+ for (const path of [
142
+ CAPTURE_ANYTHING_PATH,
143
+ TAGS_GRAPH_PATH,
144
+ CONNECT_AI_PATH,
145
+ YOURS_TO_KEEP_PATH,
146
+ ]) {
147
+ const note = await store.getNoteByPath(path);
148
+ expect(note!.tags).toEqual(["guide"]);
149
+ }
150
+
151
+ // The AI-facing Getting Started guide: guide.
152
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
153
+ expect(gs!.tags).toEqual(["guide"]);
154
+ });
155
+
107
156
  test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
108
157
  await seedOnboardingNotes(store);
109
158
  const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
@@ -121,26 +170,38 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
121
170
  expect(gs!.content).toContain("first listed value");
122
171
  });
123
172
 
124
- test("A3 (reshaped): the welcome web's wikilinks resolve; Getting Started has no dangling Surface Starter link", async () => {
173
+ test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
125
174
  await seedOnboardingNotes(store);
126
175
 
127
- // welcome → try-linking, try-linking → welcome, connect-AI → welcome.
128
176
  const welcome = await store.getNoteByPath(WELCOME_PATH);
129
- const tryLinking = await store.getNoteByPath(TRY_LINKING_PATH);
177
+ const capture = await store.getNoteByPath(CAPTURE_ANYTHING_PATH);
178
+ const tags = await store.getNoteByPath(TAGS_GRAPH_PATH);
130
179
  const connectAi = await store.getNoteByPath(CONNECT_AI_PATH);
180
+ const yours = await store.getNoteByPath(YOURS_TO_KEEP_PATH);
181
+ const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
131
182
 
132
- const welcomeOut = await store.getLinks(welcome!.id, { direction: "outbound" });
133
- expect(welcomeOut.some((l) => l.targetId === tryLinking!.id)).toBe(true);
134
- const tryOut = await store.getLinks(tryLinking!.id, { direction: "outbound" });
135
- expect(tryOut.some((l) => l.targetId === welcome!.id)).toBe(true);
136
- const connectOut = await store.getLinks(connectAi!.id, { direction: "outbound" });
137
- expect(connectOut.some((l) => l.targetId === welcome!.id)).toBe(true);
183
+ const outbound = async (id: string) =>
184
+ (await store.getLinks(id, { direction: "outbound" })).map((l) => l.targetId);
185
+
186
+ // Welcome all four siblings.
187
+ const welcomeOut = await outbound(welcome!.id);
188
+ for (const t of [capture!.id, tags!.id, connectAi!.id, yours!.id]) {
189
+ expect(welcomeOut).toContain(t);
190
+ }
191
+ // The chain forward: capture → tags → connect → yours → welcome.
192
+ expect(await outbound(capture!.id)).toContain(tags!.id);
193
+ expect(await outbound(tags!.id)).toContain(connectAi!.id);
194
+ const connectOut = await outbound(connectAi!.id);
195
+ expect(connectOut).toContain(yours!.id);
196
+ // Connect your AI also links the AI-facing Getting Started note (seeded by
197
+ // the getting-started pack) — the ring's cross-link resolves, no dangle.
198
+ expect(connectOut).toContain(gs!.id);
199
+ expect(await outbound(yours!.id)).toContain(welcome!.id);
138
200
 
139
201
  // Getting Started mentions the surface-starter PACK — not a wikilink to a
140
202
  // note that the default seed no longer creates.
141
- const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
142
203
  expect(gs!.content).not.toContain("[[Surface Starter]]");
143
- expect(gs!.content).toContain("add-pack surface-starter");
204
+ expect(gs!.content).toContain("add-pack");
144
205
  });
145
206
 
146
207
  test("idempotent: a second seed run skips all notes (does not duplicate)", async () => {
@@ -179,7 +240,8 @@ describe("applySeedPack — surface-starter via add-pack", () => {
179
240
  expect(result.pack).toBe("surface-starter");
180
241
  expect(result.seededNotes).toEqual([SURFACE_STARTER_PATH]);
181
242
  expect(result.skippedNotes).toEqual([]);
182
- expect(result.tags).toEqual([]);
243
+ // surface-starter now declares GUIDE_TAG (it's a guide).
244
+ expect(result.tags).toEqual(["guide"]);
183
245
 
184
246
  const ss = await store.getNoteByPath(SURFACE_STARTER_PATH);
185
247
  expect(ss).not.toBeNull();
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Seed the default packs on vault creation.
3
3
  *
4
- * A freshly-created vault gets the `welcome` pack (the person-voiced
5
- * three-note welcome web + the `capture` tag the Notes surface expects) and
4
+ * A freshly-created vault gets the `welcome` pack (the five-guide welcome ring
5
+ * + the `capture` / `guide` / `pinned` tags the Notes surface expects) and
6
6
  * the `getting-started` pack (the AI-legible start-here doctrine) — so the
7
7
  * first minute shows a small living graph, and a connected AI can read the
8
8
  * guide and help the operator set the vault up.
package/src/routes.ts CHANGED
@@ -2089,7 +2089,7 @@ export async function handleTags(
2089
2089
 
2090
2090
  // PUT /tags/:name — upsert tag identity row. Body accepts any combination
2091
2091
  // of { description, fields, relationships, parent_names }; omitted keys
2092
- // are preserved, explicit null clears. See patterns/tag-data-model.md.
2092
+ // are preserved, explicit null clears. See docs/contracts/tag-data-model.md.
2093
2093
  if (req.method === "PUT") {
2094
2094
  // Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
2095
2095
  // the existing-field merge read (store.getTagSchema below) and the upsert
@@ -2114,7 +2114,7 @@ export async function handleTags(
2114
2114
  * the MCP `update-tag` tool relies on (omitted keys preserved). The
2115
2115
  * Schema editor (vault#283) sends the full map + `replace_fields: true`
2116
2116
  * so removing a field row actually deletes the field. See
2117
- * patterns/tag-data-model.md.
2117
+ * docs/contracts/tag-data-model.md.
2118
2118
  */
2119
2119
  replace_fields?: unknown;
2120
2120
  };
@@ -2211,7 +2211,7 @@ export async function handleTags(
2211
2211
  // Tag-scoped tokens reference root tags by name; deleting a referenced
2212
2212
  // tag would silently orphan the token's allowlist. Fail closed (409)
2213
2213
  // and name the offending tokens so the operator can revoke or re-mint
2214
- // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
2214
+ // before retrying. docs/contracts/tag-scoped-tokens.md §Dependencies.
2215
2215
  const referenced_by = findTokensReferencingTag(store.db, tagName);
2216
2216
  if (referenced_by.length > 0) {
2217
2217
  return json(