@openparachute/vault 0.6.4-rc.1 → 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.
- package/core/src/attribution.test.ts +273 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/mcp.ts +44 -1
- package/core/src/notes.ts +115 -3
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +4 -1
- package/core/src/types.ts +33 -3
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/mcp-tools.ts +17 -2
- package/src/routes.ts +30 -1
- package/src/routing.ts +9 -1
|
@@ -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
|
+
});
|
package/core/src/cursor.ts
CHANGED
|
@@ -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
|
}
|