@openparachute/vault 0.4.3 → 0.4.4-rc.12

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/README.md CHANGED
@@ -139,7 +139,7 @@ Password and 2FA secrets live in `~/.parachute/vault/config.yaml` at mode 0600 (
139
139
 
140
140
  Where `{name}` is `default` on a fresh install, or whatever vault you pointed `vault init` at. **First MCP call after `vault init` requires no browser handoff — Claude Code uses the baked-in token and the vault's tools show up in your next session.** This is intentional: for an owner connecting their own machine's vault to their own Claude Code, the token is already there and OAuth would add friction.
141
141
 
142
- To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only, edit `~/.claude.json` and replace the `Authorization` header value with a fresh token from `parachute-vault tokens create`. (Running `parachute-vault mcp-install` on its own overwrites the MCP entry *without* an `Authorization` header and is intended for the rare case where you want to drop the token and connect via OAuth instead.)
142
+ To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only, run `parachute-vault mcp-install` (defaults to `--mint`, which mints a fresh scope-narrow hub JWT via `~/.parachute/operator.token` and writes it into `~/.claude.json` with an `Authorization: Bearer …` header). See the [cookbook](#install-vault-mcp-into-a-client-config) section below for the full flag surface token paste, scope narrowing, project-level install, multi-vault.
143
143
 
144
144
  ### Claude Desktop (OAuth)
145
145
 
@@ -190,7 +190,13 @@ parachute-vault uninstall --yes --wipe # scripted destructive wipe (prints a
190
190
  parachute-vault create work # create a new vault
191
191
  parachute-vault list # list all vaults (alias: `ls`)
192
192
  parachute-vault remove work --yes # delete a vault (alias: `rm`)
193
- parachute-vault mcp-install # (re)write the ~/.claude.json MCP entry for the default vault
193
+ parachute-vault mcp-install # (re)write the MCP client entry; defaults to --mint (hub-issued JWT) at local scope
194
+ parachute-vault mcp-install --token <t> # paste an existing bearer instead of minting
195
+ parachute-vault mcp-install --legacy-pat # mint a vault-DB pvt_* (self-hosted-without-hub)
196
+ parachute-vault mcp-install --install-scope user # write ~/.claude.json top-level (every project)
197
+ parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
198
+ parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
199
+ parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
194
200
 
195
201
  # OAuth — owner password + 2FA
196
202
  parachute-vault set-password # set/change the owner password (OAuth consent page)
@@ -565,6 +571,56 @@ Atomic at the SQL layer: two concurrent appends both land in some order, never c
565
571
 
566
572
  The shortest path to a public HTTPS URL for a vault you control — useful for SSG rebuilds running on GitHub Actions, Vercel, or any runner that isn't on your tailnet. See [Remote access via Tailscale Funnel](#remote-access-via-tailscale-funnel) below for the full setup.
567
573
 
574
+ ### Install vault MCP into a client config
575
+
576
+ Bare `parachute-vault mcp-install` from a terminal **walks you through a short contextual conversation** — picks defaults informed by your environment (how many vaults you have, whether the hub is reachable, whether you're in a project directory, whether vault is already installed somewhere), shows the JSON shape it will write before doing anything, and asks before each non-obvious choice. The patterns below are the non-interactive shapes — pass any flag (`--mint`, `--token`, `--scope`, `--install-scope`, `--vault`, `--legacy-pat`) and the walkthrough is skipped.
577
+
578
+ #### Install scopes
579
+
580
+ `--install-scope` accepts three values, matching Claude Code's own `claude mcp add --scope`:
581
+
582
+ | Scope | Where the entry lives | Visibility |
583
+ |---|---|---|
584
+ | `local` *(default)* | `~/.claude.json` under `projects[<absolute-cwd>].mcpServers` | private to your machine, scoped to this directory |
585
+ | `user` | `~/.claude.json` top-level `mcpServers` | every project, every directory on this machine |
586
+ | `project` | `<cwd>/.mcp.json` | checked into the repo, shared with anyone who clones it |
587
+
588
+ The default is **`local`** — Claude Code only loads the entry when launched from the directory you ran the install from. Pick `user` for a global install (every project sees the vault); pick `project` to commit the entry to the repo so collaborators get it on `git pull`.
589
+
590
+ #### Cookbook
591
+
592
+ ```bash
593
+ # 1. Default — mint a scope-narrow hub JWT (vault:<vault>:read) via your
594
+ # operator token; write it into ~/.claude.json under projects[<cwd>]
595
+ # (local scope, this directory only). Requires:
596
+ # - ~/.parachute/operator.token (run `parachute auth rotate-operator` if missing)
597
+ # - PARACHUTE_HUB_ORIGIN set OR an active `parachute expose` session
598
+ parachute-vault mcp-install --mint
599
+
600
+ # 2. Global install — write the entry at top-level ~/.claude.json so
601
+ # Claude Code loads it from every project, every directory.
602
+ parachute-vault mcp-install --install-scope user
603
+
604
+ # 3. Project-level install — write ./.mcp.json (committed to the repo,
605
+ # shared with the team). Pair with --scope vault:write when the
606
+ # project actually mutates the vault.
607
+ parachute-vault mcp-install --install-scope project --scope vault:write
608
+
609
+ # 4. Paste an existing token — useful when you already have a pvt_* in hand
610
+ # or want to re-use a long-lived bearer from another machine. Skips the
611
+ # mint step entirely.
612
+ parachute-vault mcp-install --token pvt_abc123...
613
+
614
+ # 5. Self-hosted-without-hub — mint a vault-DB pvt_* token (the legacy
615
+ # path; preserved so deployments without a hub keep working). Prints a
616
+ # deprecation notice.
617
+ parachute-vault mcp-install --legacy-pat
618
+ ```
619
+
620
+ **Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
621
+
622
+ **Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
623
+
568
624
  ## Data model
569
625
 
570
626
  ```
@@ -3678,6 +3678,238 @@ describe("schema validation (tags.fields)", async () => {
3678
3678
  const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3679
3679
  expect(result.validation_status).toBeUndefined();
3680
3680
  });
3681
+
3682
+ // vault#310 — integer type validation. JSON has no separate integer
3683
+ // type, so a JSON number with zero fractional part (`5`, `5.0`) must
3684
+ // pass an `integer`-typed field. Pre-fix, the validator had no
3685
+ // `"integer"` case at all — falling through the switch returned
3686
+ // undefined and every integer-typed field warned `type_mismatch` on
3687
+ // legitimate values. Gitcoin Brain's drift detector emits JSON for
3688
+ // diffs; every `kpi: 3` triggered the false-positive and buried the
3689
+ // real warnings.
3690
+
3691
+ it("integer-typed field: JSON integer (5) passes (vault#310)", async () => {
3692
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3693
+ const tools = generateMcpTools(store);
3694
+ const create = tools.find((t) => t.name === "create-note")!;
3695
+ const result = await create.execute({
3696
+ content: "x",
3697
+ tags: ["kpi"],
3698
+ metadata: { count: 5 },
3699
+ }) as any;
3700
+ expect(result.validation_status?.warnings ?? []).toEqual([]);
3701
+ });
3702
+
3703
+ it("integer-typed field: JSON `5.0` (zero-fractional) passes (vault#310)", async () => {
3704
+ // 5.0 is the canonical Gitcoin shape — JSON.parse decodes the
3705
+ // emitted JSON number as a JS Number; the JS Number for `5.0` is
3706
+ // identical to `5` so Number.isInteger reports true. This is the
3707
+ // load-bearing assertion for the Gitcoin drift-detector use case.
3708
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3709
+ const tools = generateMcpTools(store);
3710
+ const create = tools.find((t) => t.name === "create-note")!;
3711
+ const result = await create.execute({
3712
+ content: "x",
3713
+ tags: ["kpi"],
3714
+ metadata: { count: 5.0 },
3715
+ }) as any;
3716
+ expect(result.validation_status?.warnings ?? []).toEqual([]);
3717
+ });
3718
+
3719
+ it("integer-typed field: JSON `5.5` (non-zero fractional) warns type_mismatch (vault#310)", async () => {
3720
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3721
+ const tools = generateMcpTools(store);
3722
+ const create = tools.find((t) => t.name === "create-note")!;
3723
+ const result = await create.execute({
3724
+ content: "x",
3725
+ tags: ["kpi"],
3726
+ metadata: { count: 5.5 },
3727
+ }) as any;
3728
+ expect(result.validation_status.warnings.length).toBe(1);
3729
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3730
+ expect(result.validation_status.warnings[0].field).toBe("count");
3731
+ });
3732
+
3733
+ it("integer-typed field: string `\"5\"` warns type_mismatch (no string→number coercion) (vault#310)", async () => {
3734
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3735
+ const tools = generateMcpTools(store);
3736
+ const create = tools.find((t) => t.name === "create-note")!;
3737
+ const result = await create.execute({
3738
+ content: "x",
3739
+ tags: ["kpi"],
3740
+ metadata: { count: "5" },
3741
+ }) as any;
3742
+ expect(result.validation_status.warnings.length).toBe(1);
3743
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3744
+ });
3745
+
3746
+ it("integer-typed field: edge `5.0000000000001` warns type_mismatch (vault#310)", async () => {
3747
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3748
+ const tools = generateMcpTools(store);
3749
+ const create = tools.find((t) => t.name === "create-note")!;
3750
+ const result = await create.execute({
3751
+ content: "x",
3752
+ tags: ["kpi"],
3753
+ metadata: { count: 5.0000000000001 },
3754
+ }) as any;
3755
+ expect(result.validation_status.warnings.length).toBe(1);
3756
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3757
+ });
3758
+
3759
+ it("integer-typed field: boolean warns type_mismatch (vault#310)", async () => {
3760
+ // Pin that boolean is rejected (Number.isInteger(true) returns
3761
+ // false, but extra coverage in case anyone "improves" the check
3762
+ // with a looser predicate later).
3763
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3764
+ const tools = generateMcpTools(store);
3765
+ const create = tools.find((t) => t.name === "create-note")!;
3766
+ const result = await create.execute({
3767
+ content: "x",
3768
+ tags: ["kpi"],
3769
+ metadata: { count: true },
3770
+ }) as any;
3771
+ expect(result.validation_status.warnings.length).toBe(1);
3772
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3773
+ });
3774
+
3775
+ // Note on NaN/Infinity: those values pass through
3776
+ // JSON.stringify as `null`, then the validator's null short-circuit
3777
+ // (schema-defaults.ts:327 — "value === null → skip") filters them out
3778
+ // before reaching the type check. We can't observe them in
3779
+ // validation_status from this layer; a dedicated unit test against
3780
+ // `valueMatchesType` would catch the case at the inner boundary.
3781
+ // Pinned at the next layer down:
3782
+
3783
+ it("valueMatchesType('integer', ...) rejects NaN / Infinity (vault#310)", async () => {
3784
+ // Reach the unexported helper indirectly via validateNote on a
3785
+ // hand-built resolved-schemas + metadata where the value is the
3786
+ // actual NaN/Infinity (no JSON round-trip).
3787
+ const { validateNote, loadSchemaConfig } = await import("./schema-defaults.js");
3788
+ // Seed via the public surface, then load the resolved schemas
3789
+ // snapshot.
3790
+ await store.upsertTagSchema("k", { fields: { c: { type: "integer" } } });
3791
+ const resolved = loadSchemaConfig((store as any).db);
3792
+ expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.NaN } })?.warnings[0]?.reason)
3793
+ .toBe("type_mismatch");
3794
+ expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.POSITIVE_INFINITY } })?.warnings[0]?.reason)
3795
+ .toBe("type_mismatch");
3796
+ });
3797
+ });
3798
+
3799
+ // ---------------------------------------------------------------------------
3800
+ // update-note `if_missing: "create"` — idempotent upsert (vault#309)
3801
+ // ---------------------------------------------------------------------------
3802
+
3803
+ describe("update-note if_missing=create (vault#309)", async () => {
3804
+ let store: SqliteStore;
3805
+ beforeEach(() => {
3806
+ store = new SqliteStore(new Database(":memory:"));
3807
+ });
3808
+
3809
+ it("creates the note when missing + carries created: true", async () => {
3810
+ const tools = generateMcpTools(store);
3811
+ const update = tools.find((t) => t.name === "update-note")!;
3812
+ const result = await update.execute({
3813
+ id: "Inbox/2026-05-13-meeting",
3814
+ content: "agenda body",
3815
+ tags: ["meeting"],
3816
+ metadata: { priority: "high" },
3817
+ if_missing: "create",
3818
+ }) as any;
3819
+ expect(result.created).toBe(true);
3820
+ expect(result.path).toBe("Inbox/2026-05-13-meeting");
3821
+ expect(result.content).toBe("agenda body");
3822
+ expect(result.tags).toContain("meeting");
3823
+ expect(result.metadata?.priority).toBe("high");
3824
+
3825
+ // And the row landed.
3826
+ const fetched = await store.getNoteByPath("Inbox/2026-05-13-meeting");
3827
+ expect(fetched).not.toBeNull();
3828
+ });
3829
+
3830
+ it("updates the note when present + carries created: false", async () => {
3831
+ await store.createNote("original", { path: "p", metadata: { v: 1 } });
3832
+ const tools = generateMcpTools(store);
3833
+ const update = tools.find((t) => t.name === "update-note")!;
3834
+ const result = await update.execute({
3835
+ id: "p",
3836
+ content: "updated body",
3837
+ metadata: { v: 2 },
3838
+ if_missing: "create",
3839
+ force: true, // bypass OC since this is an unconditional update
3840
+ }) as any;
3841
+ expect(result.created).toBe(false);
3842
+ expect(result.content).toBe("updated body");
3843
+ expect(result.metadata?.v).toBe(2);
3844
+ });
3845
+
3846
+ it("without if_missing, missing note errors (current behavior — back-compat)", async () => {
3847
+ const tools = generateMcpTools(store);
3848
+ const update = tools.find((t) => t.name === "update-note")!;
3849
+ await expect(update.execute({
3850
+ id: "nope",
3851
+ content: "x",
3852
+ force: true,
3853
+ })).rejects.toThrow(/Note not found/);
3854
+ });
3855
+
3856
+ it("create branch applies tag-schema defaults when the new tag declares fields", async () => {
3857
+ await store.upsertTagSchema("task", {
3858
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
3859
+ });
3860
+ const tools = generateMcpTools(store);
3861
+ const update = tools.find((t) => t.name === "update-note")!;
3862
+ const result = await update.execute({
3863
+ id: "Inbox/new-task",
3864
+ content: "do the thing",
3865
+ tags: ["task"],
3866
+ if_missing: "create",
3867
+ }) as any;
3868
+ expect(result.created).toBe(true);
3869
+ // Schema defaults populated metadata.priority on insert.
3870
+ expect(result.metadata?.priority).toBeDefined();
3871
+ });
3872
+
3873
+ it("create branch surfaces validation warnings just like create-note", async () => {
3874
+ await store.upsertTagSchema("task", {
3875
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
3876
+ });
3877
+ const tools = generateMcpTools(store);
3878
+ const update = tools.find((t) => t.name === "update-note")!;
3879
+ const result = await update.execute({
3880
+ id: "Inbox/bad-task",
3881
+ content: "x",
3882
+ tags: ["task"],
3883
+ metadata: { priority: "ULTRA" },
3884
+ if_missing: "create",
3885
+ }) as any;
3886
+ expect(result.created).toBe(true);
3887
+ expect(result.validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
3888
+ });
3889
+
3890
+ it("idempotent: second call with same id + same content updates without error", async () => {
3891
+ const tools = generateMcpTools(store);
3892
+ const update = tools.find((t) => t.name === "update-note")!;
3893
+ const first = await update.execute({
3894
+ id: "Inbox/sync-target",
3895
+ content: "v1",
3896
+ if_missing: "create",
3897
+ }) as any;
3898
+ expect(first.created).toBe(true);
3899
+
3900
+ const second = await update.execute({
3901
+ id: "Inbox/sync-target",
3902
+ content: "v2",
3903
+ if_missing: "create",
3904
+ force: true,
3905
+ }) as any;
3906
+ expect(second.created).toBe(false);
3907
+ expect(second.content).toBe("v2");
3908
+
3909
+ // Only one row exists.
3910
+ const all = await store.queryNotes({ limit: 100 });
3911
+ expect(all.filter((n) => n.path === "Inbox/sync-target")).toHaveLength(1);
3912
+ });
3681
3913
  });
3682
3914
 
3683
3915
  // ---------------------------------------------------------------------------
package/core/src/mcp.ts CHANGED
@@ -469,6 +469,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
469
469
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
470
470
  - For batch: pass a \`notes\` array, each with an \`id\` field.
471
471
  - **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. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
472
+ - **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.
472
473
  - \`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.`,
473
474
  inputSchema: {
474
475
  type: "object",
@@ -491,6 +492,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
491
492
  created_at: { type: "string", description: "New created_at timestamp" },
492
493
  if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
493
494
  force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
495
+ if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
494
496
  tags: {
495
497
  type: "object",
496
498
  properties: {
@@ -554,6 +556,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
554
556
  created_at: { type: "string" },
555
557
  if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
556
558
  force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
559
+ if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
557
560
  tags: { type: "object" },
558
561
  links: { type: "object" },
559
562
  },
@@ -572,6 +575,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
572
575
  }
573
576
 
574
577
  const updated: Note[] = [];
578
+ // Track which note IDs were freshly created via `if_missing: "create"`
579
+ // so the response can carry `created: true|false` per-note. The
580
+ // sync-loop caller (Gitcoin Brain et al) reads this to know which
581
+ // path fired without doing a separate query. vault#309.
582
+ const createdIds = new Set<string>();
575
583
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
576
584
  // failure (precondition error, content_edit miss, ConflictError, …)
577
585
  // rolls back every prior mutation in the batch — see #236.
@@ -581,7 +589,87 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
581
589
  if (batched) db.exec("BEGIN");
582
590
  try {
583
591
  for (const item of items) {
584
- const note = requireNote(db, item.id as string);
592
+ // Try ID-then-path resolve. If not found AND
593
+ // `if_missing: "create"` is set, fall through to the create
594
+ // branch using this same item's payload. Otherwise mirror the
595
+ // existing `requireNote` behavior (throw "Note not found").
596
+ // vault#309.
597
+ const resolved = resolveNote(db, item.id as string);
598
+ if (!resolved) {
599
+ if (item.if_missing === "create") {
600
+ // Treat the update payload as a create payload. Minimum:
601
+ // content OR a path/id (something the createNote-empty-row
602
+ // invariant accepts). createNote enforces its own
603
+ // not-both-empty check — we leave that to the Store and
604
+ // surface any error to the caller verbatim.
605
+ //
606
+ // Field mapping (mirrors the create-note tool surface):
607
+ // - `item.id` → both the note's `id` AND a fallback
608
+ // `path` when `item.path` isn't set. Treating `id` as
609
+ // the path-or-id lookup key matches Gitcoin's nightly
610
+ // sync shape where the canonical key is a path string
611
+ // like "Inbox/2026-05-13-meeting". If the caller
612
+ // supplied an opaque ULID as `id` and no `path`, we
613
+ // still create with that as `id` (path stays null).
614
+ // - `item.content` / `item.path` / `item.tags` /
615
+ // `item.metadata` / `item.created_at` → forwarded.
616
+ // - `if_updated_at` / `force` / `content_edit` /
617
+ // `append` / `prepend` are update-only — silently
618
+ // ignored on the create branch. (Content-edit on a
619
+ // non-existent note is a nonsense combination; the
620
+ // caller's intent on missing-note is "create the
621
+ // row", not "patch in this section".)
622
+ // - `links.remove` is also ignored on create (nothing
623
+ // to remove on a fresh note).
624
+ // - `links.add` IS applied below — the drift sync can
625
+ // declare typed links at upsert time and have them
626
+ // materialize alongside the create. See vault#320
627
+ // reviewer F1 — the prior comment claimed all
628
+ // `links` were ignored, but `links.add` was already
629
+ // processed and used by Gitcoin's sync; the
630
+ // misleading wording is fixed here so a future
631
+ // reader doesn't trust it and break the workflow.
632
+ const idOrPath = item.id as string;
633
+ // Heuristic: if `path` isn't set AND the `id` looks like a
634
+ // path (contains "/" or doesn't match a typical opaque-id
635
+ // shape), use it as the path too. Otherwise treat it as a
636
+ // pure id. The shared `id` field for update is ID-or-path
637
+ // already (see `resolveNote`), so this preserves the
638
+ // caller's intent.
639
+ const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
640
+ const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
641
+ const createOpts: Parameters<Store["createNote"]>[1] = {
642
+ ...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
643
+ ...(item.tags && Array.isArray((item.tags as any).add)
644
+ ? { tags: (item.tags as any).add as string[] }
645
+ : Array.isArray(item.tags)
646
+ ? { tags: item.tags as string[] }
647
+ : {}),
648
+ ...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
649
+ ...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
650
+ };
651
+ const content = (item.content as string | undefined) ?? "";
652
+ const created = await store.createNote(content, createOpts);
653
+ await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
654
+ // Apply links.add if the caller declared any.
655
+ const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
656
+ if (linksAdd) {
657
+ for (const link of linksAdd) {
658
+ const target = resolveNote(db, link.target);
659
+ if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
660
+ }
661
+ }
662
+ const fresh = noteOps.getNote(db, created.id) ?? created;
663
+ updated.push(fresh);
664
+ createdIds.add(fresh.id);
665
+ continue;
666
+ }
667
+ // Fallthrough: not-found + no if_missing → existing error
668
+ // contract. Match `requireNote`'s message shape so existing
669
+ // callers see no behavior change.
670
+ throw new Error(`Note not found: "${item.id}"`);
671
+ }
672
+ const note = resolved;
585
673
 
586
674
  // --- Validate mutual exclusion of content modes ---
587
675
  const hasContent = item.content !== undefined;
@@ -745,14 +833,21 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
745
833
  // Response shape: full Note (back-compat default) or lean NoteIndex
746
834
  // (#285 friction point 2.response — opt-out for callers making
747
835
  // frequent small edits to large notes). `validation_status` from
748
- // `tags.fields` is preserved across either shape.
836
+ // `tags.fields` is preserved across either shape. `created: true|false`
837
+ // (vault#309) is attached to every response so callers using
838
+ // `if_missing: "create"` can tell which branch fired without a
839
+ // separate query. `false` for the (overwhelmingly common) update
840
+ // path; `true` only when this call took the create-on-missing
841
+ // branch.
749
842
  const includeContent = params.include_content !== false;
750
843
  const final = updated.map((n) => {
751
844
  const validated = attachValidationStatus(store, db, n);
752
- if (includeContent) return validated;
845
+ const created = createdIds.has(n.id);
846
+ if (includeContent) return { ...validated, created } as Note & { created: boolean };
753
847
  const lean: any = noteOps.toNoteIndex(validated);
754
848
  const vs = (validated as any).validation_status;
755
849
  if (vs !== undefined) lean.validation_status = vs;
850
+ lean.created = created;
756
851
  return lean;
757
852
  });
758
853
  return batch ? final : final[0];
@@ -1127,8 +1222,13 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
1127
1222
  *
1128
1223
  * Returns the note unchanged when no tag declares fields, so callers
1129
1224
  * without any tag schemas see no behavior change.
1225
+ *
1226
+ * Exported so both transports (MCP `update-note` here, HTTP `PATCH
1227
+ * /api/notes/:id` in `src/routes.ts`) attach the same status field by
1228
+ * the same recipe — see vault#287 for the asymmetry that motivated
1229
+ * exposing it.
1130
1230
  */
1131
- function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1231
+ export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1132
1232
  // Short-circuit cheaply: when no tag declares fields, the resolver
1133
1233
  // returns null without us paying a re-read of the note.
1134
1234
  const status = store.validateNoteAgainstSchemas({