@openparachute/vault 0.6.3 → 0.6.4-rc.2

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,273 @@
1
+ /**
2
+ * Write-attribution (vault#298) — per-identity (`*_by`) + per-interface
3
+ * (`*_via`) provenance at the store layer.
4
+ *
5
+ * Covers the core contract:
6
+ * - create stamps BOTH axes onto created_* AND mirrors into last_updated_*
7
+ * - update bumps ONLY last_updated_* (created_* is set-once)
8
+ * - a `skipUpdatedAt` machine write does NOT claim authorship
9
+ * - no-attribution writes leave the columns NULL (never "")
10
+ * - the four query-notes filters (created_by / last_updated_by /
11
+ * created_via / last_updated_via)
12
+ * - legacy rows written before the v23 migration stay NULL and don't crash
13
+ * - the cursor binds the attribution filters (changing them invalidates it)
14
+ *
15
+ * The auth → AuthResult → MCP/REST threading is exercised separately in
16
+ * src/attribution-threading.test.ts (it needs the server-side auth context).
17
+ */
18
+ import { describe, it, expect, beforeEach } from "bun:test";
19
+ import { Database } from "bun:sqlite";
20
+ import { SqliteStore } from "./store.js";
21
+ import { initSchema, SCHEMA_VERSION } from "./schema.js";
22
+
23
+ let store: SqliteStore;
24
+ let db: Database;
25
+
26
+ beforeEach(() => {
27
+ db = new Database(":memory:");
28
+ store = new SqliteStore(db);
29
+ });
30
+
31
+ function rawRow(id: string) {
32
+ return db
33
+ .prepare(
34
+ "SELECT created_by, created_via, last_updated_by, last_updated_via FROM notes WHERE id = ?",
35
+ )
36
+ .get(id) as {
37
+ created_by: string | null;
38
+ created_via: string | null;
39
+ last_updated_by: string | null;
40
+ last_updated_via: string | null;
41
+ };
42
+ }
43
+
44
+ describe("write-attribution — schema", () => {
45
+ it("bumped SCHEMA_VERSION to 23", () => {
46
+ expect(SCHEMA_VERSION).toBe(23);
47
+ });
48
+
49
+ it("the four columns exist and are indexed", () => {
50
+ const cols = (db.prepare("PRAGMA table_info(notes)").all() as { name: string }[]).map(
51
+ (r) => r.name,
52
+ );
53
+ expect(cols).toContain("created_by");
54
+ expect(cols).toContain("created_via");
55
+ expect(cols).toContain("last_updated_by");
56
+ expect(cols).toContain("last_updated_via");
57
+
58
+ const idx = (
59
+ db
60
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'")
61
+ .all() as { name: string }[]
62
+ ).map((r) => r.name);
63
+ expect(idx).toContain("idx_notes_created_by");
64
+ expect(idx).toContain("idx_notes_created_via");
65
+ expect(idx).toContain("idx_notes_last_updated_by");
66
+ expect(idx).toContain("idx_notes_last_updated_via");
67
+ });
68
+ });
69
+
70
+ describe("write-attribution — per-identity (WHO) + per-interface (VIA)", () => {
71
+ it("create captures actor + via on BOTH the created_* and last_updated_* pairs", async () => {
72
+ const note = await store.createNote("hi", { actor: "aaron", via: "mcp" });
73
+ // Read-model surfaces both axes.
74
+ expect(note.createdBy).toBe("aaron");
75
+ expect(note.createdVia).toBe("mcp");
76
+ // First write IS the most-recent write — mirrored.
77
+ expect(note.lastUpdatedBy).toBe("aaron");
78
+ expect(note.lastUpdatedVia).toBe("mcp");
79
+
80
+ const row = rawRow(note.id);
81
+ expect(row.created_by).toBe("aaron");
82
+ expect(row.created_via).toBe("mcp");
83
+ expect(row.last_updated_by).toBe("aaron");
84
+ expect(row.last_updated_via).toBe("mcp");
85
+ });
86
+
87
+ it("update bumps last_updated_* but leaves created_* untouched (set-once)", async () => {
88
+ const note = await store.createNote("v1", { actor: "aaron", via: "mcp" });
89
+ const updated = await store.updateNote(note.id, {
90
+ content: "v2",
91
+ actor: "mathilda",
92
+ via: "surface:meeting-ingest",
93
+ });
94
+
95
+ // created_* pins the ORIGINAL author/channel forever.
96
+ expect(updated.createdBy).toBe("aaron");
97
+ expect(updated.createdVia).toBe("mcp");
98
+ // last_updated_* reflects the most recent writer.
99
+ expect(updated.lastUpdatedBy).toBe("mathilda");
100
+ expect(updated.lastUpdatedVia).toBe("surface:meeting-ingest");
101
+
102
+ const row = rawRow(note.id);
103
+ expect(row.created_by).toBe("aaron");
104
+ expect(row.last_updated_by).toBe("mathilda");
105
+ expect(row.last_updated_via).toBe("surface:meeting-ingest");
106
+ });
107
+
108
+ it("append/prepend updates also record the most-recent author", async () => {
109
+ const note = await store.createNote("base", { actor: "aaron", via: "mcp" });
110
+ const after = await store.updateNote(note.id, {
111
+ append: " +more",
112
+ actor: "agent:nightly",
113
+ via: "agent:nightly",
114
+ });
115
+ expect(after.content).toBe("base +more");
116
+ expect(after.createdBy).toBe("aaron");
117
+ expect(after.lastUpdatedBy).toBe("agent:nightly");
118
+ expect(after.lastUpdatedVia).toBe("agent:nightly");
119
+ });
120
+
121
+ it("a skipUpdatedAt machine write does NOT claim authorship", async () => {
122
+ const note = await store.createNote("x", { actor: "aaron", via: "mcp" });
123
+ const before = rawRow(note.id);
124
+ // A hook-style marker write: passes actor/via but skipUpdatedAt — should
125
+ // not bump updated_at NOR rewrite last_updated_*.
126
+ await store.updateNote(note.id, {
127
+ metadata: { marker: true },
128
+ skipUpdatedAt: true,
129
+ actor: "some-hook",
130
+ via: "operator",
131
+ });
132
+ const after = rawRow(note.id);
133
+ expect(after.last_updated_by).toBe(before.last_updated_by); // still "aaron"
134
+ expect(after.last_updated_by).toBe("aaron");
135
+ expect(after.last_updated_via).toBe("mcp");
136
+ });
137
+
138
+ it("an attribution-less update honestly clears the most-recent author (NULL, not stale)", async () => {
139
+ const note = await store.createNote("x", { actor: "aaron", via: "mcp" });
140
+ // A real edit arriving with no attribution context — the latest writer is
141
+ // unknown; we must NOT leave "aaron" as the apparent last editor.
142
+ const after = await store.updateNote(note.id, { content: "y" });
143
+ expect(after.createdBy).toBe("aaron"); // original author preserved
144
+ expect(after.lastUpdatedBy).toBeNull();
145
+ expect(after.lastUpdatedVia).toBeNull();
146
+ });
147
+ });
148
+
149
+ describe("write-attribution — no context → NULL (never empty string)", () => {
150
+ it("create without actor/via leaves all four columns NULL", async () => {
151
+ const note = await store.createNote("anon");
152
+ const row = rawRow(note.id);
153
+ expect(row.created_by).toBeNull();
154
+ expect(row.created_via).toBeNull();
155
+ expect(row.last_updated_by).toBeNull();
156
+ expect(row.last_updated_via).toBeNull();
157
+ expect(note.createdBy).toBeNull();
158
+ expect(note.createdVia).toBeNull();
159
+ });
160
+
161
+ it("empty / whitespace-only actor/via normalize to NULL", async () => {
162
+ const note = await store.createNote("e", { actor: " ", via: "" });
163
+ const row = rawRow(note.id);
164
+ expect(row.created_by).toBeNull();
165
+ expect(row.created_via).toBeNull();
166
+ });
167
+ });
168
+
169
+ describe("write-attribution — query filters", () => {
170
+ beforeEach(async () => {
171
+ await store.createNote("a", { actor: "aaron", via: "mcp" });
172
+ await store.createNote("b", { actor: "mathilda", via: "surface:meeting-ingest" });
173
+ const c = await store.createNote("c", { actor: "aaron", via: "api" });
174
+ // c's most-recent write is by the nightly agent via mcp.
175
+ await store.updateNote(c.id, { content: "c2", actor: "agent:nightly", via: "mcp" });
176
+ await store.createNote("legacy"); // no attribution
177
+ });
178
+
179
+ it("created_by selects only that principal's CREATED notes", async () => {
180
+ const aaron = await store.queryNotes({ createdBy: "aaron" });
181
+ expect(aaron.map((n) => n.content).sort()).toEqual(["a", "c2"]);
182
+ });
183
+
184
+ it("last_updated_by selects by MOST-RECENT author (agent's edit on c)", async () => {
185
+ const byAgent = await store.queryNotes({ lastUpdatedBy: "agent:nightly" });
186
+ expect(byAgent.map((n) => n.content)).toEqual(["c2"]);
187
+ // aaron created c but is no longer its last editor.
188
+ const aaronLatest = await store.queryNotes({ lastUpdatedBy: "aaron" });
189
+ expect(aaronLatest.map((n) => n.content)).toEqual(["a"]);
190
+ });
191
+
192
+ it("created_via / last_updated_via filter on the interface axis", async () => {
193
+ const viaSurface = await store.queryNotes({ createdVia: "surface:meeting-ingest" });
194
+ expect(viaSurface.map((n) => n.content)).toEqual(["b"]);
195
+ const lastViaMcp = await store.queryNotes({ lastUpdatedVia: "mcp" });
196
+ // "a" (created via mcp, never updated) + "c2" (last updated via mcp).
197
+ expect(lastViaMcp.map((n) => n.content).sort()).toEqual(["a", "c2"]);
198
+ });
199
+
200
+ it("an attribution filter never matches legacy/unattributed (NULL) rows", async () => {
201
+ const any = await store.queryNotes({ createdBy: "aaron" });
202
+ expect(any.some((n) => n.content === "legacy")).toBe(false);
203
+ });
204
+ });
205
+
206
+ describe("write-attribution — legacy backfill (v23 migration)", () => {
207
+ it("a pre-v23 vault: existing rows stay NULL, new rows attribute, no crash", () => {
208
+ // Build a v22-shaped notes table by hand (no attribution columns) +
209
+ // schema_version row, then run initSchema to drive the v23 migration.
210
+ const legacy = new Database(":memory:");
211
+ legacy.exec(`
212
+ CREATE TABLE notes (
213
+ id TEXT PRIMARY KEY,
214
+ content TEXT DEFAULT '',
215
+ path TEXT,
216
+ metadata TEXT DEFAULT '{}',
217
+ created_at TEXT NOT NULL,
218
+ updated_at TEXT,
219
+ extension TEXT NOT NULL DEFAULT 'md'
220
+ );
221
+ CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT);
222
+ INSERT INTO schema_version (version, applied_at) VALUES (22, '2026-01-01T00:00:00.000Z');
223
+ INSERT INTO notes (id, content, created_at, updated_at)
224
+ VALUES ('old-1', 'pre-existing', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
225
+ `);
226
+
227
+ // Should migrate without throwing.
228
+ initSchema(legacy);
229
+
230
+ const ver = (legacy.prepare("SELECT MAX(version) AS v FROM schema_version").get() as {
231
+ v: number;
232
+ }).v;
233
+ expect(ver).toBe(23);
234
+
235
+ // Legacy row: attribution columns exist now but are NULL (not fabricated).
236
+ const old = legacy
237
+ .prepare("SELECT created_by, last_updated_by, created_via FROM notes WHERE id = ?")
238
+ .get("old-1") as { created_by: string | null; last_updated_by: string | null; created_via: string | null };
239
+ expect(old.created_by).toBeNull();
240
+ expect(old.last_updated_by).toBeNull();
241
+ expect(old.created_via).toBeNull();
242
+
243
+ // A NEW write on the migrated DB attributes correctly.
244
+ const migratedStore = new SqliteStore(legacy);
245
+ return migratedStore.createNote("fresh", { actor: "aaron", via: "mcp" }).then((n) => {
246
+ expect(n.createdBy).toBe("aaron");
247
+ expect(n.createdVia).toBe("mcp");
248
+ // The legacy row is still NULL and still queryable.
249
+ return migratedStore.queryNotes({ createdBy: "aaron" }).then((res) => {
250
+ expect(res.map((x) => x.content)).toEqual(["fresh"]);
251
+ });
252
+ });
253
+ });
254
+
255
+ it("re-running the v23 migration is idempotent (no duplicate-column error)", () => {
256
+ const again = new Database(":memory:");
257
+ const s = new SqliteStore(again); // runs migrations once
258
+ initSchema(again); // run again — must not throw
259
+ expect(() => s.createNote("ok", { actor: "x", via: "api" })).not.toThrow();
260
+ });
261
+ });
262
+
263
+ describe("write-attribution — cursor binding", () => {
264
+ it("changing an attribution filter between cursor polls invalidates the cursor", async () => {
265
+ await store.createNote("a", { actor: "aaron", via: "mcp" });
266
+ const page = await store.queryNotesPaged({ createdBy: "aaron" });
267
+ // Reusing the cursor under a DIFFERENT createdBy must be rejected, not
268
+ // silently continue aaron's watermark.
269
+ await expect(
270
+ store.queryNotesPaged({ createdBy: "mathilda", cursor: page.next_cursor }),
271
+ ).rejects.toThrow(/different query/i);
272
+ });
273
+ });
@@ -160,6 +160,14 @@ export interface QueryHashInputs {
160
160
  extension?: string | string[];
161
161
  ids?: string[];
162
162
  metadata?: Record<string, unknown>;
163
+ // Write-attribution filters (vault#298) — part of the result-set-affecting
164
+ // opts, so they're bound into the cursor: changing "who/via" between polls
165
+ // invalidates the cursor with cursor_query_mismatch rather than silently
166
+ // continuing the prior filter's watermark.
167
+ createdBy?: string;
168
+ lastUpdatedBy?: string;
169
+ createdVia?: string;
170
+ lastUpdatedVia?: string;
163
171
  dateFrom?: string;
164
172
  dateTo?: string;
165
173
  dateFilter?: { field?: string; from?: string; to?: string };
package/core/src/mcp.ts CHANGED
@@ -120,6 +120,16 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
120
120
  * expansion behaves exactly as before.
121
121
  */
122
122
  export interface GenerateMcpToolsOpts {
123
+ /**
124
+ * Write-attribution context (vault#298) stamped onto every note written
125
+ * through these tools. `actor` is the principal (JWT `sub` / operator
126
+ * label); `via` is the interface the write arrived through (here, always an
127
+ * MCP session — the server-side wrapper derives `mcp` or a more specific
128
+ * `agent:<id>` / `surface:<name>` when the token's claims reveal it). The
129
+ * core tools pass it straight into `store.createNote` / `store.updateNote`.
130
+ * Omitted (internal / unattributed callers) → writes leave attribution NULL.
131
+ */
132
+ writeContext?: { actor?: string | null; via?: string | null };
123
133
  expandVisibility?: (note: Note) => boolean;
124
134
  /**
125
135
  * `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
@@ -141,6 +151,11 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
141
151
  const db: Database = store.db;
142
152
  const expandVisibility = opts?.expandVisibility;
143
153
  const nearTraversable = opts?.nearTraversable;
154
+ // Write-attribution (vault#298) — captured once at tool-generation time
155
+ // (a fresh tool set is generated per MCP request, so this is request-scoped)
156
+ // and folded into every create/update the tools perform.
157
+ const writeActor = opts?.writeContext?.actor ?? null;
158
+ const writeVia = opts?.writeContext?.via ?? null;
144
159
 
145
160
  return [
146
161
 
@@ -220,6 +235,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
220
235
  type: "object",
221
236
  description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
222
237
  },
238
+ created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
239
+ last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
240
+ created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
241
+ last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
223
242
  order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
224
243
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
225
244
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
@@ -460,6 +479,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
460
479
  // result whenever the neighborhood lies outside that prefix.
461
480
  ids: nearScope ? [...nearScope] : undefined,
462
481
  metadata: params.metadata as Record<string, unknown> | undefined,
482
+ // Write-attribution filters (vault#298): "who wrote / via what."
483
+ createdBy: params.created_by as string | undefined,
484
+ lastUpdatedBy: params.last_updated_by as string | undefined,
485
+ createdVia: params.created_via as string | undefined,
486
+ lastUpdatedVia: params.last_updated_via as string | undefined,
463
487
  dateFrom: params.date_from as string | undefined,
464
488
  dateTo: params.date_to as string | undefined,
465
489
  dateFilter: params.date_filter as
@@ -643,6 +667,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
643
667
  metadata: item.metadata as Record<string, unknown> | undefined,
644
668
  created_at: item.created_at as string | undefined,
645
669
  ...(extension !== undefined ? { extension } : {}),
670
+ // Write-attribution (vault#298) — same actor/via for every item
671
+ // in a batch (the whole call came from one authenticated session).
672
+ actor: writeActor,
673
+ via: writeVia,
646
674
  });
647
675
 
648
676
  // Create explicit links (not wikilinks — those are automatic)
@@ -714,7 +742,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
714
742
  - For batch: pass a \`notes\` array, each with an \`id\` field.
715
743
  - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
716
744
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
717
- - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
745
+ - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
746
+
747
+ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
718
748
  inputSchema: {
719
749
  type: "object",
720
750
  properties: {
@@ -913,6 +943,12 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
913
943
  ...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
914
944
  ...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
915
945
  ...(createExt !== undefined ? { extension: createExt } : {}),
946
+ // Write-attribution (vault#298) — the if_missing:"create" upsert
947
+ // branch is still a CREATE, so it must stamp the same actor/via
948
+ // as the create-note tool + the REST upsert-create path. Without
949
+ // this an MCP-driven upsert-create wrote NULL attribution.
950
+ actor: writeActor,
951
+ via: writeVia,
916
952
  };
917
953
  const content = (item.content as string | undefined) ?? "";
918
954
  const created = await store.createNote(content, createOpts);
@@ -1060,6 +1096,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1060
1096
 
1061
1097
  let result: Note;
1062
1098
  if (Object.keys(updates).length > 0) {
1099
+ // Write-attribution (vault#298): stamp the most-recent-write
1100
+ // columns on the same UPDATE that bumps `updated_at`. Only set when
1101
+ // there's a real change to write (the empty-updates branch below
1102
+ // leaves attribution untouched, symmetric with not bumping
1103
+ // updated_at on a no-op).
1104
+ updates.actor = writeActor;
1105
+ updates.via = writeVia;
1063
1106
  // store.updateNote routes through noteOps.updateNote, which runs
1064
1107
  // the UPDATE (with optional `AND updated_at IS ?`) atomically and
1065
1108
  // throws ConflictError on mismatch. No mutations have happened
package/core/src/notes.ts CHANGED
@@ -22,6 +22,36 @@ import { getIndexedField, releaseField } from "./indexed-fields.js";
22
22
 
23
23
  let idCounter = 0;
24
24
 
25
+ /**
26
+ * Write-attribution context (vault#298) — the two axes of provenance threaded
27
+ * from the authenticated request down into every note write.
28
+ *
29
+ * actor — WHO: the principal the write is attributed to (a JWT `sub`, or an
30
+ * operator / `token:<id>` label for non-JWT auth). Lands in
31
+ * `created_by` on the first write and `last_updated_by` on every
32
+ * write.
33
+ * via — VIA WHAT: the interface/channel the write arrived through
34
+ * (`mcp`, `surface:<name>`, `agent:<id>`, `operator`/`cli`, `api`).
35
+ * Lands in `created_via` / `last_updated_via` symmetrically.
36
+ *
37
+ * Both are independently optional — an internal/import write may carry
38
+ * neither, and a non-JWT operator write carries an `actor`/`via` pair without
39
+ * a `sub`. `undefined` (or a missing context) means "don't write this column,"
40
+ * so legacy callers and importers leave attribution NULL rather than
41
+ * fabricating it.
42
+ */
43
+ export interface WriteContext {
44
+ actor?: string | null;
45
+ via?: string | null;
46
+ }
47
+
48
+ /** Normalize an attribution value: empty/whitespace → null (never store ""). */
49
+ function attrValue(v: string | null | undefined): string | null {
50
+ if (typeof v !== "string") return null;
51
+ const t = v.trim();
52
+ return t.length === 0 ? null : t;
53
+ }
54
+
25
55
  /** Generate a timestamp-based ID: YYYY-MM-DD-HH-MM-SS-ffffff */
26
56
  export function generateId(): string {
27
57
  const now = new Date();
@@ -41,7 +71,7 @@ export function generateId(): string {
41
71
  export function createNote(
42
72
  db: Database,
43
73
  content: string,
44
- opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string },
74
+ opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null },
45
75
  ): Note {
46
76
  const id = opts?.id ?? generateId();
47
77
  const createdAt = opts?.created_at ?? new Date().toISOString();
@@ -52,6 +82,14 @@ export function createNote(
52
82
  // whatever the caller passed; importer paths trust the export's shape.
53
83
  const extension = opts?.extension ?? "md";
54
84
 
85
+ // Write-attribution (vault#298). On CREATE both axes land in the
86
+ // `created_*` columns AND mirror into `last_updated_*` — the first write IS
87
+ // the most-recent write, so a never-updated note reports the same author on
88
+ // both. NULL when the caller passed no attribution (internal/import writes),
89
+ // never an empty string.
90
+ const actor = attrValue(opts?.actor);
91
+ const via = attrValue(opts?.via);
92
+
55
93
  // Empty content is a valid state (vault#323): skeleton notes, drafts
56
94
  // saved before content, organizing-only notes, capture-then-fill flows.
57
95
  // The earlier #213 guard rejected `content + path both absent`; we no
@@ -66,8 +104,8 @@ export function createNote(
66
104
  // "user-touched since creation."
67
105
  try {
68
106
  db.prepare(
69
- `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension) VALUES (?, ?, ?, ?, ?, ?, ?)`,
70
- ).run(id, content, path, metadata, createdAt, createdAt, extension);
107
+ `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
108
+ ).run(id, content, path, metadata, createdAt, createdAt, extension, actor, via, actor, via);
71
109
  } catch (err) {
72
110
  if (path !== null && isPathUniqueError(err)) {
73
111
  throw new PathConflictError(path);
@@ -321,6 +359,16 @@ export function updateNote(
321
359
  metadata?: Record<string, unknown>;
322
360
  created_at?: string;
323
361
  skipUpdatedAt?: boolean;
362
+ /**
363
+ * Write-attribution (vault#298). When set, the most-recent-write columns
364
+ * `last_updated_by` / `last_updated_via` are bumped to this principal /
365
+ * channel as part of the same UPDATE. Gated on the same condition as
366
+ * `updated_at` — a `skipUpdatedAt` machine write doesn't claim authorship,
367
+ * symmetric with not bumping the timestamp. `created_*` is never touched by
368
+ * an update (it's set-once at create). Omitted → attribution left as-is.
369
+ */
370
+ actor?: string | null;
371
+ via?: string | null;
324
372
  /**
325
373
  * Optimistic concurrency token. When provided, the UPDATE runs with an
326
374
  * additional `AND updated_at IS ?` clause; if no row is affected and the
@@ -358,6 +406,17 @@ export function updateNote(
358
406
  }
359
407
  sets.push("updated_at = ?");
360
408
  values.push(now);
409
+
410
+ // Write-attribution (vault#298): the most-recent-write columns ride the
411
+ // SAME gate as `updated_at`. A `skipUpdatedAt` machine write doesn't bump
412
+ // the timestamp, so it doesn't claim authorship either. Set unconditionally
413
+ // within this branch (even to NULL) so a write that arrives without
414
+ // attribution honestly records "unknown author for the latest edit" rather
415
+ // than leaving a stale prior principal — the latest writer wasn't them.
416
+ sets.push("last_updated_by = ?");
417
+ values.push(attrValue(updates.actor));
418
+ sets.push("last_updated_via = ?");
419
+ values.push(attrValue(updates.via));
361
420
  }
362
421
 
363
422
  if (updates.content !== undefined) {
@@ -601,6 +660,29 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
601
660
  }
602
661
  }
603
662
 
663
+ // Write-attribution filters (vault#298). Exact match on the indexed
664
+ // attribution columns. Each rides its own B-tree (idx_notes_<col>), so
665
+ // "everything Mathilda wrote" / "everything via the meeting-ingest surface"
666
+ // is a seek, not a scan. NULL columns never match a non-null filter value
667
+ // (SQL `=` is unknown against NULL), so legacy/unattributed rows are
668
+ // correctly excluded from an attribution query.
669
+ if (opts.createdBy !== undefined) {
670
+ conditions.push("n.created_by = ?");
671
+ params.push(opts.createdBy);
672
+ }
673
+ if (opts.lastUpdatedBy !== undefined) {
674
+ conditions.push("n.last_updated_by = ?");
675
+ params.push(opts.lastUpdatedBy);
676
+ }
677
+ if (opts.createdVia !== undefined) {
678
+ conditions.push("n.created_via = ?");
679
+ params.push(opts.createdVia);
680
+ }
681
+ if (opts.lastUpdatedVia !== undefined) {
682
+ conditions.push("n.last_updated_via = ?");
683
+ params.push(opts.lastUpdatedVia);
684
+ }
685
+
604
686
  // Metadata filters — operator objects route through the indexed generated
605
687
  // column (fast, loud errors on non-indexed fields); primitives keep the
606
688
  // existing JSON-scan exact-match behavior for backcompat.
@@ -898,6 +980,10 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
898
980
  extension: opts.extension,
899
981
  ids: opts.ids,
900
982
  metadata: opts.metadata,
983
+ createdBy: opts.createdBy,
984
+ lastUpdatedBy: opts.lastUpdatedBy,
985
+ createdVia: opts.createdVia,
986
+ lastUpdatedVia: opts.lastUpdatedVia,
901
987
  dateFrom: opts.dateFrom,
902
988
  dateTo: opts.dateTo,
903
989
  dateFilter: opts.dateFilter,
@@ -1587,6 +1673,10 @@ export function toNoteIndex(note: Note): NoteIndex {
1587
1673
  extension: note.extension,
1588
1674
  createdAt: note.createdAt,
1589
1675
  updatedAt: note.updatedAt,
1676
+ createdBy: note.createdBy ?? null,
1677
+ createdVia: note.createdVia ?? null,
1678
+ lastUpdatedBy: note.lastUpdatedBy ?? null,
1679
+ lastUpdatedVia: note.lastUpdatedVia ?? null,
1590
1680
  tags: note.tags,
1591
1681
  metadata: note.metadata,
1592
1682
  byteSize,
@@ -1703,6 +1793,10 @@ export interface BulkNoteInput {
1703
1793
  metadata?: Record<string, unknown>;
1704
1794
  created_at?: string;
1705
1795
  extension?: string;
1796
+ /** Write-attribution (vault#298) — see WriteContext. Per-item so a batch can
1797
+ * carry the same actor/via on every row. */
1798
+ actor?: string | null;
1799
+ via?: string | null;
1706
1800
  }
1707
1801
 
1708
1802
  export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
@@ -1719,6 +1813,8 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1719
1813
  metadata: input.metadata,
1720
1814
  created_at: input.created_at,
1721
1815
  extension: input.extension,
1816
+ actor: input.actor,
1817
+ via: input.via,
1722
1818
  }),
1723
1819
  );
1724
1820
  }
@@ -1787,6 +1883,14 @@ interface NoteRow {
1787
1883
  created_at: string;
1788
1884
  updated_at: string | null;
1789
1885
  extension: string | null;
1886
+ // Write-attribution (vault#298). All four nullable — NULL on legacy rows and
1887
+ // on writes that carried no attribution context. A v22 vault reading these
1888
+ // before its migration runs would see them absent on the row object
1889
+ // (`SELECT *` simply omits non-existent columns); `?? null` normalizes.
1890
+ created_by?: string | null;
1891
+ created_via?: string | null;
1892
+ last_updated_by?: string | null;
1893
+ last_updated_via?: string | null;
1790
1894
  }
1791
1895
 
1792
1896
  function rowToNote(row: NoteRow): Note {
@@ -1807,5 +1911,13 @@ function rowToNote(row: NoteRow): Note {
1807
1911
  // Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at
1808
1912
  // so the optimistic-concurrency contract always has a real token to echo.
1809
1913
  updatedAt: row.updated_at ?? row.created_at,
1914
+ // Write-attribution (vault#298). NULL passes through verbatim — a missing
1915
+ // author is meaningful ("pre-attribution / unknown"), so we don't coerce
1916
+ // to a placeholder. `?? null` collapses both SQL NULL and an absent column
1917
+ // (pre-migration read) to the same `null`.
1918
+ createdBy: row.created_by ?? null,
1919
+ createdVia: row.created_via ?? null,
1920
+ lastUpdatedBy: row.last_updated_by ?? null,
1921
+ lastUpdatedVia: row.last_updated_via ?? null,
1810
1922
  };
1811
1923
  }