@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -1,9 +1,18 @@
1
1
  /**
2
- * Tag schema CRUD — DB-backed storage for tag metadata schemas.
2
+ * Tag record CRUD — DB-backed storage for the per-tag identity row.
3
3
  *
4
- * Each tag can optionally have a schema that describes expected metadata
5
- * fields for notes with that tag. Schemas drive auto-population of defaults
6
- * and soft warnings on create/tag operations.
4
+ * Each tag row carries: human-readable description, indexed metadata field
5
+ * declarations (`fields`), typed-link relationship declarations
6
+ * (`relationships`), and the hierarchy parent list (`parent_names`).
7
+ * See parachute-patterns/patterns/tag-data-model.md.
8
+ *
9
+ * This module retains the historical `tag-schemas` filename and exports
10
+ * (`TagSchema`, `listTagSchemas`, `getTagSchema`, `upsertTagSchema`,
11
+ * `deleteTagSchema`, `getTagSchemaMap`) as a thin schema-only facade —
12
+ * callers that only care about `description + fields` keep working without
13
+ * change. New surface (`TagRecord`, `listTagRecords`, `getTagRecord`,
14
+ * `upsertTagRecord`) returns the full row including relationships and
15
+ * parent_names.
7
16
  */
8
17
 
9
18
  import { Database } from "bun:sqlite";
@@ -23,87 +32,278 @@ export interface TagFieldSchema {
23
32
  indexed?: boolean;
24
33
  }
25
34
 
35
+ /**
36
+ * Cardinality vocabulary for typed relationships. Names rather than
37
+ * algebra so AI clients reading `list-tags` can reason about intent
38
+ * directly. Phase 1 is informational — declarations are not enforced
39
+ * at write time. See patterns/tag-data-model.md §Typed relationships.
40
+ */
41
+ export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
42
+
43
+ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
44
+ "one",
45
+ "optional",
46
+ "many",
47
+ "many-required",
48
+ ] as const;
49
+
50
+ export interface TagRelationship {
51
+ target_tag: string;
52
+ cardinality: TagRelCardinality;
53
+ description?: string;
54
+ }
55
+
56
+ /**
57
+ * Schema-only view of a tag — the historical shape. Backwards-compatible
58
+ * with v13-and-earlier callers.
59
+ */
26
60
  export interface TagSchema {
27
61
  tag: string;
28
62
  description?: string;
29
63
  fields?: Record<string, TagFieldSchema>;
30
64
  }
31
65
 
32
- // DB row shape
33
- interface TagSchemaRow {
34
- tag_name: string;
66
+ /**
67
+ * Full tag record — schema + typed relationships + hierarchy parents.
68
+ */
69
+ export interface TagRecord extends TagSchema {
70
+ relationships?: Record<string, TagRelationship>;
71
+ parent_names?: string[];
72
+ created_at?: string;
73
+ updated_at?: string;
74
+ }
75
+
76
+ // DB row shape (post-v14 `tags` table).
77
+ interface TagRow {
78
+ name: string;
35
79
  description: string | null;
36
80
  fields: string | null;
81
+ relationships: string | null;
82
+ parent_names: string | null;
83
+ created_at: string | null;
84
+ updated_at: string | null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // CRUD — full record
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** List all tag records, sorted by name. */
92
+ export function listTagRecords(db: Database): TagRecord[] {
93
+ const rows = db.prepare(
94
+ "SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags ORDER BY name",
95
+ ).all() as TagRow[];
96
+ return rows.map(rowToRecord);
97
+ }
98
+
99
+ /** Get a single tag record, or null if the tag doesn't exist. */
100
+ export function getTagRecord(db: Database, tag: string): TagRecord | null {
101
+ const row = db.prepare(
102
+ "SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags WHERE name = ?",
103
+ ).get(tag) as TagRow | undefined;
104
+ return row ? rowToRecord(row) : null;
105
+ }
106
+
107
+ /**
108
+ * Upsert a tag record — partial update. Any field left `undefined` is
109
+ * preserved. Pass `null` explicitly to clear a column. Always touches
110
+ * `updated_at`; sets `created_at` on first insert.
111
+ */
112
+ export function upsertTagRecord(
113
+ db: Database,
114
+ tag: string,
115
+ patch: {
116
+ description?: string | null;
117
+ fields?: Record<string, TagFieldSchema> | null;
118
+ relationships?: Record<string, TagRelationship> | null;
119
+ parent_names?: string[] | null;
120
+ },
121
+ ): TagRecord {
122
+ const now = new Date().toISOString();
123
+ db.prepare(
124
+ "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
125
+ ).run(tag, now, now);
126
+
127
+ const existing = getTagRecord(db, tag);
128
+
129
+ const description =
130
+ patch.description === undefined ? (existing?.description ?? null) : patch.description;
131
+ const fields =
132
+ patch.fields === undefined
133
+ ? jsonOrNull(existing?.fields)
134
+ : jsonOrNull(patch.fields);
135
+ const relationships =
136
+ patch.relationships === undefined
137
+ ? jsonOrNull(existing?.relationships)
138
+ : jsonOrNull(patch.relationships);
139
+ const parent_names =
140
+ patch.parent_names === undefined
141
+ ? jsonOrNull(existing?.parent_names)
142
+ : jsonOrNull(patch.parent_names);
143
+
144
+ db.prepare(
145
+ `UPDATE tags
146
+ SET description = ?, fields = ?, relationships = ?, parent_names = ?, updated_at = ?
147
+ WHERE name = ?`,
148
+ ).run(description, fields, relationships, parent_names, now, tag);
149
+
150
+ return getTagRecord(db, tag)!;
37
151
  }
38
152
 
39
153
  // ---------------------------------------------------------------------------
40
- // CRUD
154
+ // CRUD — schema-only facade (back-compat)
41
155
  // ---------------------------------------------------------------------------
42
156
 
43
- /** List all tag schemas. */
157
+ /** List schema-only views for tags that have a description or fields set. */
44
158
  export function listTagSchemas(db: Database): TagSchema[] {
45
- const rows = db.prepare("SELECT * FROM tag_schemas ORDER BY tag_name").all() as TagSchemaRow[];
46
- return rows.map(rowToSchema);
159
+ const rows = db.prepare(
160
+ "SELECT name, description, fields FROM tags WHERE description IS NOT NULL OR fields IS NOT NULL ORDER BY name",
161
+ ).all() as { name: string; description: string | null; fields: string | null }[];
162
+ return rows.map((r) => ({
163
+ tag: r.name,
164
+ description: r.description ?? undefined,
165
+ fields: parseJson<Record<string, TagFieldSchema>>(r.fields),
166
+ }));
47
167
  }
48
168
 
49
- /** Get a single tag's schema, or null if none defined. */
169
+ /**
170
+ * Schema-only view for a single tag. Returns null if the tag has neither
171
+ * a description nor fields (matches v13 behavior, where the absence of a
172
+ * `tag_schemas` row meant "no schema").
173
+ */
50
174
  export function getTagSchema(db: Database, tag: string): TagSchema | null {
51
- const row = db.prepare("SELECT * FROM tag_schemas WHERE tag_name = ?").get(tag) as TagSchemaRow | undefined;
52
- return row ? rowToSchema(row) : null;
175
+ const row = db.prepare(
176
+ "SELECT name, description, fields FROM tags WHERE name = ?",
177
+ ).get(tag) as { name: string; description: string | null; fields: string | null } | undefined;
178
+ if (!row) return null;
179
+ if (row.description === null && row.fields === null) return null;
180
+ return {
181
+ tag: row.name,
182
+ description: row.description ?? undefined,
183
+ fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
184
+ };
53
185
  }
54
186
 
55
187
  /** Get all schemas as a lookup map (tag → schema). Used by schema effects. */
56
- export function getTagSchemaMap(db: Database): Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> {
57
- const schemas = listTagSchemas(db);
188
+ export function getTagSchemaMap(
189
+ db: Database,
190
+ ): Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> {
58
191
  const map: Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> = {};
59
- for (const s of schemas) {
192
+ for (const s of listTagSchemas(db)) {
60
193
  map[s.tag] = { description: s.description, fields: s.fields };
61
194
  }
62
195
  return map;
63
196
  }
64
197
 
65
198
  /**
66
- * Create or replace a tag schema (upsert).
67
- * Ensures the tag exists in the tags table first.
199
+ * Set or replace a tag's schema (description + fields). Other columns
200
+ * (`relationships`, `parent_names`) are left untouched. Idempotent.
68
201
  */
69
202
  export function upsertTagSchema(
70
203
  db: Database,
71
204
  tag: string,
72
205
  schema: { description?: string; fields?: Record<string, TagFieldSchema> },
73
206
  ): TagSchema {
74
- // Ensure tag exists
75
- db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tag);
76
-
77
- const fieldsJson = schema.fields ? JSON.stringify(schema.fields) : null;
78
- db.prepare(`
79
- INSERT INTO tag_schemas (tag_name, description, fields)
80
- VALUES (?, ?, ?)
81
- ON CONFLICT(tag_name) DO UPDATE SET
82
- description = excluded.description,
83
- fields = excluded.fields
84
- `).run(tag, schema.description ?? null, fieldsJson);
85
-
86
- return getTagSchema(db, tag)!;
207
+ upsertTagRecord(db, tag, {
208
+ description: schema.description ?? null,
209
+ fields: schema.fields ?? null,
210
+ });
211
+ return getTagSchema(db, tag) ?? { tag, description: schema.description, fields: schema.fields };
87
212
  }
88
213
 
89
- /** Delete a tag's schema. Returns true if a schema was deleted. */
214
+ /**
215
+ * Clear a tag's schema (description + fields). Returns true if anything
216
+ * was cleared. Other columns and the tag row itself are preserved — to
217
+ * delete the tag entirely, use `noteOps.deleteTag`.
218
+ */
90
219
  export function deleteTagSchema(db: Database, tag: string): boolean {
91
- const result = db.prepare("DELETE FROM tag_schemas WHERE tag_name = ?").run(tag);
92
- return result.changes > 0;
220
+ const before = getTagSchema(db, tag);
221
+ if (!before) return false;
222
+ db.prepare(
223
+ "UPDATE tags SET description = NULL, fields = NULL, updated_at = ? WHERE name = ?",
224
+ ).run(new Date().toISOString(), tag);
225
+ return true;
93
226
  }
94
227
 
95
228
  // ---------------------------------------------------------------------------
96
- // Helpers
229
+ // Validation — typed relationships
97
230
  // ---------------------------------------------------------------------------
98
231
 
99
- function rowToSchema(row: TagSchemaRow): TagSchema {
100
- let fields: Record<string, TagFieldSchema> | undefined;
101
- if (row.fields) {
102
- try { fields = JSON.parse(row.fields); } catch {}
232
+ /**
233
+ * Validate a `relationships` payload before persisting. Returns the
234
+ * canonicalized object on success; throws Error with a user-readable
235
+ * message on the first violation. Rules:
236
+ *
237
+ * - Each value must declare `target_tag` (non-empty string) and
238
+ * `cardinality` from the named vocabulary.
239
+ * - `description` is optional, must be a string when present.
240
+ * - Relationship keys must be non-empty strings.
241
+ */
242
+ export function validateRelationships(
243
+ raw: unknown,
244
+ ): Record<string, TagRelationship> {
245
+ if (raw === null || raw === undefined) {
246
+ throw new Error("relationships: expected an object, got null/undefined");
247
+ }
248
+ if (typeof raw !== "object" || Array.isArray(raw)) {
249
+ throw new Error("relationships: expected an object mapping rel name → declaration");
103
250
  }
251
+ const out: Record<string, TagRelationship> = {};
252
+ for (const [rel, decl] of Object.entries(raw as Record<string, unknown>)) {
253
+ if (!rel || typeof rel !== "string") {
254
+ throw new Error("relationships: keys must be non-empty strings");
255
+ }
256
+ if (!decl || typeof decl !== "object" || Array.isArray(decl)) {
257
+ throw new Error(`relationships["${rel}"]: declaration must be an object`);
258
+ }
259
+ const d = decl as Record<string, unknown>;
260
+ if (typeof d.target_tag !== "string" || d.target_tag.length === 0) {
261
+ throw new Error(`relationships["${rel}"]: target_tag must be a non-empty string`);
262
+ }
263
+ const card = d.cardinality;
264
+ if (typeof card !== "string" || !TAG_REL_CARDINALITIES.includes(card as TagRelCardinality)) {
265
+ throw new Error(
266
+ `relationships["${rel}"]: cardinality must be one of ${TAG_REL_CARDINALITIES.join(" | ")}; got ${JSON.stringify(card)}`,
267
+ );
268
+ }
269
+ if (d.description !== undefined && typeof d.description !== "string") {
270
+ throw new Error(`relationships["${rel}"]: description must be a string when set`);
271
+ }
272
+ out[rel] = {
273
+ target_tag: d.target_tag,
274
+ cardinality: card as TagRelCardinality,
275
+ ...(d.description !== undefined ? { description: d.description as string } : {}),
276
+ };
277
+ }
278
+ return out;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Helpers
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function rowToRecord(row: TagRow): TagRecord {
104
286
  return {
105
- tag: row.tag_name,
287
+ tag: row.name,
106
288
  description: row.description ?? undefined,
107
- fields,
289
+ fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
290
+ relationships: parseJson<Record<string, TagRelationship>>(row.relationships),
291
+ parent_names: parseJson<string[]>(row.parent_names),
292
+ created_at: row.created_at ?? undefined,
293
+ updated_at: row.updated_at ?? undefined,
108
294
  };
109
295
  }
296
+
297
+ function parseJson<T>(raw: string | null): T | undefined {
298
+ if (raw === null || raw === undefined) return undefined;
299
+ try { return JSON.parse(raw) as T; } catch { return undefined; }
300
+ }
301
+
302
+ function jsonOrNull(value: unknown): string | null {
303
+ if (value === null || value === undefined) return null;
304
+ if (typeof value === "string") {
305
+ // Already-encoded payload (e.g. when copying from an existing row).
306
+ return value;
307
+ }
308
+ return JSON.stringify(value);
309
+ }
package/core/src/types.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
2
+
3
+ // ---- Re-exports ----
4
+
5
+ export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
6
+
1
7
  // ---- Note ----
2
8
 
3
9
  export interface Note {
@@ -41,6 +47,7 @@ export interface VaultStats {
41
47
  notesByMonth: { month: string; count: number }[];
42
48
  topTags: { tag: string; count: number }[];
43
49
  tagCount: number;
50
+ attachmentCount: number;
44
51
  linkCount: number;
45
52
  }
46
53
 
@@ -57,14 +64,35 @@ export interface QueryOpts {
57
64
  hasLinks?: boolean;
58
65
  path?: string; // exact path match (case-insensitive)
59
66
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
67
+ // Restrict results to a specific set of note IDs. The MCP `near` query uses
68
+ // this to push graph-neighborhood scoping into the SQL WHERE clause so that
69
+ // LIMIT and ORDER BY apply to the filtered set, not the whole notes table.
70
+ // Empty array → no rows match (avoids `IN ()` syntax error).
71
+ ids?: string[];
60
72
  // Per-field metadata filter. Each value is either a primitive (exact
61
73
  // match, today's behavior) or an operator object — `{ eq, ne, gt, gte, lt,
62
74
  // lte, in, not_in, exists }` — which routes through the generated column
63
75
  // for the field. Operator queries require the field to be declared
64
76
  // `indexed: true` in a tag schema; undeclared fields error loudly.
65
77
  metadata?: Record<string, unknown>;
78
+ // Legacy shorthand: filters on `n.created_at` (vault ingestion time).
79
+ // Equivalent to `dateFilter: { field: "created_at", from, to }`. Kept
80
+ // as the common path; specifying both this and `dateFilter` rejects.
66
81
  dateFrom?: string; // ISO date
67
82
  dateTo?: string; // ISO date
83
+ // Generalized date range. `field` defaults to `created_at`; `updated_at`
84
+ // is also a recognized real column (the incremental-rebuild path —
85
+ // vault#285 1.5). Any other field must be declared `indexed: true` in a
86
+ // tag schema (so the SQL hits a real B-tree index, same contract as
87
+ // `metadata` operator queries and `orderBy`). Use this to filter on a
88
+ // *content* date — an email's received date, a meeting's scheduled
89
+ // date — rather than the ingestion timestamp, or on `updated_at` to ask
90
+ // "what changed since X."
91
+ dateFilter?: {
92
+ field?: string;
93
+ from?: string;
94
+ to?: string;
95
+ };
68
96
  sort?: "asc" | "desc";
69
97
  // Sort by an indexed metadata field instead of `created_at`. Must be
70
98
  // declared `indexed: true`; errors loudly otherwise. Direction is taken
@@ -114,7 +142,7 @@ export interface Store {
114
142
  getNote(id: string): Promise<Note | null>;
115
143
  getNoteByPath(path: string): Promise<Note | null>;
116
144
  getNotes(ids: string[]): Promise<Note[]>;
117
- updateNote(id: string, updates: { content?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
145
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
118
146
  deleteNote(id: string): Promise<void>;
119
147
  queryNotes(opts: QueryOpts): Promise<Note[]>;
120
148
  searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]>;
@@ -122,12 +150,31 @@ export interface Store {
122
150
  // Tags
123
151
  tagNote(noteId: string, tags: string[]): Promise<void>;
124
152
  untagNote(noteId: string, tags: string[]): Promise<void>;
153
+ /**
154
+ * Expand a set of tag names to the union of `{tag} ∪ descendants(tag)` for
155
+ * each input, using the `_tags/<name>` config-note hierarchy. Always
156
+ * includes each input tag in the result. Used by tag-scoped tokens to
157
+ * compute the effective allowlisted tag-set at auth time.
158
+ */
159
+ expandTagsWithDescendants(tags: string[]): Promise<Set<string>>;
125
160
  listTags(): Promise<{ name: string; count: number }[]>;
126
161
  deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
127
162
  renameTag(
128
163
  oldName: string,
129
164
  newName: string,
130
- ): Promise<{ renamed: number } | { error: "not_found" } | { error: "target_exists" }>;
165
+ ): Promise<
166
+ | {
167
+ renamed: number;
168
+ sub_tags_renamed: number;
169
+ parent_refs_updated: number;
170
+ tokens_updated: number;
171
+ indexed_field_declarers_updated: number;
172
+ notes_rewritten: number;
173
+ paths_renamed: number;
174
+ }
175
+ | { error: "not_found" }
176
+ | { error: "target_exists"; conflicting: string[] }
177
+ >;
131
178
  mergeTags(
132
179
  sources: string[],
133
180
  target: string,
@@ -151,12 +198,54 @@ export interface Store {
151
198
  traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): Promise<{ noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[]>;
152
199
  findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<{ path: string[]; relationships: string[] } | null>;
153
200
 
154
- // Tag schemas
155
- listTagSchemas(): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }[]>;
156
- getTagSchema(tag: string): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> } | null>;
157
- upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }>;
201
+ // Tag schemas — schema-only facade (description + fields). Back-compat
202
+ // surface for v13-and-earlier callers; reads/writes route through the
203
+ // post-v14 `tags` row directly.
204
+ listTagSchemas(): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }[]>;
205
+ getTagSchema(tag: string): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> } | null>;
206
+ upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }>;
158
207
  deleteTagSchema(tag: string): Promise<boolean>;
159
- getTagSchemaMap(): Promise<Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }>>;
208
+ getTagSchemaMap(): Promise<Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }>>;
209
+
210
+ // Tag records — full v14 identity row (description + fields + typed
211
+ // relationships + parent_names + timestamps). See
212
+ // parachute-patterns/patterns/tag-data-model.md.
213
+ listTagRecords(): Promise<TagRecord[]>;
214
+ getTagRecord(tag: string): Promise<TagRecord | null>;
215
+ /**
216
+ * Partial upsert. Any patch field left undefined is preserved; pass
217
+ * null to clear. Touching `parent_names` invalidates the tag-hierarchy
218
+ * cache. Returns the post-write row.
219
+ */
220
+ upsertTagRecord(
221
+ tag: string,
222
+ patch: {
223
+ description?: string | null;
224
+ fields?: Record<string, TagFieldSchema> | null;
225
+ relationships?: Record<string, TagRelationship> | null;
226
+ parent_names?: string[] | null;
227
+ },
228
+ ): Promise<TagRecord>;
229
+
230
+ // Schema validation (post-v17: backed by `tags.fields` only — the
231
+ // standalone note_schemas + schema_mappings subsystem retired in v17, see
232
+ // vault#267). Post vault#270 the resolver walks `parent_names` so a note's
233
+ // effective fields include all ancestors' declarations (first-in-walk wins
234
+ // on conflict, surfaced as `schema_conflict` warnings); a tag named
235
+ // `_default` is the implicit universal parent. Returns null when no
236
+ // ancestor declares any fields. The underlying resolver is in-memory after
237
+ // the first lazy load.
238
+ validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): {
239
+ schemas: string[];
240
+ warnings: {
241
+ field: string;
242
+ schema: string;
243
+ reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
244
+ message: string;
245
+ /** Set only on `schema_conflict` — the tag whose declaration was overridden. */
246
+ loser_schema?: string;
247
+ }[];
248
+ } | null;
160
249
 
161
250
  // Attachments
162
251
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;