diff --git a/.drafts/convenience-combine.md b/.drafts/convenience-combine.md deleted file mode 100644 index 296ac4b..0000000 --- a/.drafts/convenience-combine.md +++ /dev/null @@ -1,58 +0,0 @@ - - -# Drafts — Artefact.combine convenience wrapper - -Drafts only. Branch `10-convenience-combine`. Per `.claude/settings.json` no -`git commit / push / add` was run. - ---- - -## Draft commit message — `artefact` - -``` -feat(artefact): pipeline-friendly combine/3 - -Artefact.combine(heart, other, opts \\ []) is a convenience wrapper around -Artefact.Binding.find/2 + Artefact.harmonise/4 designed for pipelines — -the heart flows through the pipe as the first argument: - - me_knowing - |> Artefact.combine(me_valuing) - |> Artefact.combine(me_being) - |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me") - -Bindings are auto-found via shared uuids; opts pass through to harmonise -for :title and :base_label overrides. :description is patched onto the -result (since harmonise itself does not yet honour :description in opts). -Raises MatchError when there are no shared nodes. - -Provenance: :harmonised, with the calling module. Combine is sugar over -harmonise — the underlying operation IS a harmonise, so the trace stays -honest. The convenience is the binding-find and the heart-first arg order. -``` - -## Notes for the next yarn - -- **harmonise/compose could honour :description in opts.** Right now combine - patches it post-hoc. If you later extend `do_harmonise/5` and `do_compose/4` - to read `:description` from opts and pass it into `build/1`, combine can - drop the patch and just delegate. Worth a small follow-up issue, not part - of this branch. - -- **Portmanteau base_label grows.** Each combine step concatenates the - heart and other base_labels. Through a five-step pipeline that becomes a - long word like `KnowingValuingBeingKnowingMoreDoing`. The final step's - opts can rename it (`base_label: "MeMind"`), which is what the example - in the docstring shows. Worth a note in any usage example. - -- **No `combine!` variant.** combine raises MatchError on no bindings, which - matches the user's original livebook helper. If we later want a - `combine_with/3` that explicitly accepts `inject:` bindings, that's a new - function — keep combine as the simple shared-uuid case. - ---- - -*Held in the commons.* diff --git a/.drafts/mermaid-export.md b/.drafts/mermaid-export.md deleted file mode 100644 index 1ac5cb3..0000000 --- a/.drafts/mermaid-export.md +++ /dev/null @@ -1,156 +0,0 @@ - - -# Drafts — Mermaid export for Artefact + ArtefactKino - -These are drafts only. Per `.claude/settings.json` no `git commit / push / add` -was run; per AGENTS.md the work belongs to the relation, not to the agent. -Review, edit, and use as you see fit. - ---- - -## Draft commit message — `artefact` - -``` -feat(artefact): add Mermaid export - -Artefact.Mermaid.export/2 emits Mermaid `graph` source from an -%Artefact{}, sitting alongside Artefact.Cypher and Artefact.Arrows -as a third derived form. - -- legacy `graph` syntax for broad renderer reach (GitHub, Notion, - mdBook, Livebook) -- nodes render as circles (`id(("..."))`) — property-graph - convention, matches the vis-network ellipses in the heartside - panel -- node label mirrors the ArtefactKino vis-network panel: - name (or id) on top, semantic labels joined with a space below, - separated by
-- base_label is dropped from per-node labels at output time, - consistent with the Cypher exporter's effective_labels rule -- artefact.title becomes Mermaid front-matter `title:` plus an - `accTitle:` line for screen readers; nil title omits both -- :direction option (:LR default, :RL :TB :BT :TD) -- escape rules: - * double quote in node label -> " - * pipe in edge label -> | - * YAML-unsafe chars in title -> double-quoted scalar with \" - and \\ escaped - -Lossy: position, style, and properties beyond `name` are not -represented — Mermaid is a render concern, not a persistence form. - -Fixture added at test/data/us_two/mermaid.mmd; ExUnit cases cover -the us_two round-trip, direction option, both escapes, the empty -graph, and the no-name fallback to node id. -``` - -## Draft commit message — `artefact` (branch `9-support-description`) - -``` -feat(artefact): optional description field, surfaced as Mermaid accDescr - -%Artefact{} now carries an optional :description alongside :title. -It defaults to nil and is only emitted into Mermaid when set. - -- new field on the struct + typespec -- Artefact.new accepts description: passthrough; macro docstring updated -- Artefact.Arrows round-trips description as a top-level "description" - key (peer of "title", may be null) -- Artefact.Mermaid emits accDescr (inline form for single-line, - block form `accDescr { ... }` when the description contains - newlines); placed between accTitle and the node lines -- ArtefactKino inspector grows a `description` row on the Artefact tab - -AGENTS.md schema updated. - -compose/harmonise are deliberately left to default description to nil — -combining two descriptions is a judgement call we don't want the -library making silently. Pass description: in opts when needed. -``` - -## Draft commit message — `artefact_kino` - -``` -feat(artefact_kino): MERMAID button on the export panel - -Adds Artefact.Mermaid.export/1 alongside CREATE / MERGE / JSON -in the export panel of the three-panel widget. Pure text output -for now — copy with click-to-select, same as the existing buttons. - -Live Mermaid rendering via mermaid.js was discussed but deferred — -keeping the panel symmetric with the other text exports for this -pass. -``` - ---- - -## Draft issue — *Live Mermaid rendering in ArtefactKino* - -**Title:** `artefact_kino: render Mermaid live in the export panel` - -**Body:** - -> Today the MERMAID button shows the source as text, the same as -> CREATE / MERGE / JSON. Useful for copying out, less useful as a -> second view of the graph next to the vis-network panel. -> -> A follow-up would load mermaid.js from CDN (matching the existing -> vis-network bootstrap) and render the diagram inside the export -> panel, with a small toggle to flip back to source view. -> -> ### Why it might be worth doing -> -> - vis-network is force-directed; Mermaid layouts are deterministic. -> Two renderings of the same artefact, side by side, can show -> structure that one alone does not. -> - Mermaid is what most readers paste into a doc. Seeing it render -> the same way it will appear in the doc closes a feedback loop. -> -> ### Why we deferred it -> -> - The current panel is symmetric across CREATE / MERGE / JSON / MERMAID -> as text. Adding a render mode for one of them breaks that symmetry. -> - mermaid.js is a heavier CDN load than vis-network. Worth measuring -> before adding. -> - The vis-network panel already does live rendering — the question is -> whether a second live view earns its keep. -> -> ### Sketch -> -> - keep the `MERMAID` button -> - add a small `source / rendered` switch that only appears when -> MERMAID is selected -> - bootstrap mermaid.js the same way `loadVis()` bootstraps vis-network -> - render into a `
` swapped in for the `
`
-
----
-
-## Draft issue — *Mermaid fixtures for the remaining test data sets*
-
-**Title:** `artefact: add mermaid.mmd fixtures for artefact_*, artefactory, lexical_categories, create_merge`
-
-**Body:**
-
-> `test/data/us_two/mermaid.mmd` is in. The other fixture folders
-> (`artefact`, `artefact_combine`, `artefact_harmonise`, `artefactory`,
-> `lexical_categories`, `create_merge`) all have `arrows.json` plus
-> Cypher fixtures but no Mermaid one yet.
->
-> Either:
-> 1. Generate fixtures by running `Artefact.Mermaid.export/1` once
->    against each, eyeball the output, commit. (Risk: locks in
->    whatever the implementation does today.)
-> 2. Hand-author each one, then assert the export matches. (Slower,
->    but each fixture acts as a spec for what the diagram should
->    say to a reader.)
->
-> Recommendation: option 2 for `artefact` and `artefactory` (the
-> self-describing artefacts — the fixture *is* the documentation),
-> option 1 for the rest.
-
----
-
-*The artefact belongs to the edge.*
diff --git a/.gitignore b/.gitignore
index 2a4c8e6..0371853 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,7 @@ artefactory-*.tar
 
 # Temporary files, for example, from tests.
 /tmp/
-
+/drafts
 /.elixir_ls
 
 .DS_Store
diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md
index 558b582..4bec341 100644
--- a/artefact/CHANGELOG.md
+++ b/artefact/CHANGELOG.md
@@ -5,6 +5,10 @@ SPDX-License-Identifier: MIT
 
 # Changelog
 
+## 0.1.4 — 2026-05-05
+
+- `Artefact.graft/3` — pipeline-friendly convenience for extending an artefact with new nodes and relationships declared inline (same shape as `Artefact.new`); every node in args MUST carry `:uuid` (no auto-find — uuid is the binding); nodes whose uuid lives in left bind to it (labels unioned, properties merged left-wins), nodes with new uuids are added; opts honour `:title` and `:description`; raises `ArgumentError` for missing uuid, duplicate keys, or relationship referencing an unknown key; records `:grafted` provenance source
+
 ## 0.1.3 — 2026-04-30
 
 - `Artefact.Mermaid.export/2` — derives Mermaid `graph` source from an `%Artefact{}`, alongside `Artefact.Cypher` and `Artefact.Arrows`; nodes render as circles, `:direction` option for `:LR`, `:RL`, `:TB`, `:BT`, `:TD`
diff --git a/artefact/README.md b/artefact/README.md
index 7251dc6..dd42bef 100644
--- a/artefact/README.md
+++ b/artefact/README.md
@@ -76,19 +76,44 @@ Artefact.Cypher.create(us_two)
 Artefact.Arrows.to_json(us_two)
 ```
 
-## Combining Artefacts
+## Combining and Extending Artefacts
 
 ```elixir
 # compose — disjoint union, nodes remain independent
 combined = Artefact.compose(a1, a2)
 
-# harmonise — merge nodes bound by shared uuid
-# lower uuid wins identity, labels are unioned, left (heartside) wins on property conflict
+# combine — pipeline-friendly union; bindings auto-found via shared uuid.
+# Delegates to harmonise. Raises MatchError if no shared nodes.
+my_knowing
+|> Artefact.combine(my_valuing)
+|> Artefact.combine(my_being)
+|> Artefact.combine(my_doing, title: "MeMind", description: "Mind of Me")
+
+# harmonise — union via declared bindings.
+# Lower uuid wins identity, labels are unioned, left wins on property conflict.
 {:ok, bindings} = Artefact.Binding.find(a1, a2)
 harmonised = Artefact.harmonise(a1, a2, bindings)
+
+# graft — extend an existing artefact inline with new nodes and
+# relationships. args matches Artefact.new's inline shape, but every
+# node MUST carry :uuid (no auto-find — uuid is the binding).
+# Nodes whose uuid lives in left bind to it (labels unioned, properties
+# merged left-wins). Nodes with new uuids are added.
+me_mind
+|> Artefact.graft(
+     [
+       nodes: [
+         {:me,          [uuid: "019ddb71-c70b-7b3e-83b1-58f4d0be2852"]},   # bind-only
+         {:stewardship, [labels: ["Knowing"], uuid: "019df318-698c-77d6-bc7b-ea041a019a7f"]}
+       ],
+       relationships: [[from: :me, type: "KNOWING", to: :stewardship]]
+     ],
+     title: "MeMind + Stewardship",
+     description: "Stewardship grafted onto MeMind."
+   )
 ```
 
-Provenance is recorded automatically — every artefact carries metadata describing how it was created, including the module it was built in and, for harmonised artefacts, the title, base_label and uuid of each source.
+Provenance is recorded automatically — every artefact carries metadata describing how it was created, including the calling module and, for derived artefacts, a summary of each source.
 
 ## Importing from Arrows JSON
 
diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex
index 94ab2b1..c30e5a8 100644
--- a/artefact/lib/artefact.ex
+++ b/artefact/lib/artefact.ex
@@ -7,11 +7,42 @@ defmodule Artefact do
   expressed as a property graph.
 
   The canonical form is the `%Artefact{}` struct. Arrows JSON and Cypher are
-  derived representations: JSON for interchange and visual editing, Cypher for
-  persistence.
+  derived representations: JSON for interchange and visual editing, Cypher
+  for persistence.
+
+  ## Operations
+
+    * `new/1` — build an artefact, inline (`:nodes` + `:relationships`) or
+      from a pre-built `%Artefact.Graph{}`.
+    * `compose/3` — concatenate two artefacts; nodes remain disjoint.
+    * `combine/3` — pipeline-friendly union; bindings auto-found via shared
+      uuid; delegates to `harmonise/4`.
+    * `harmonise/4` — union via declared bindings; lower uuid wins identity,
+      labels are unioned, left wins on property conflict.
+    * `graft/3` — pipeline-friendly extension; integrates inline `args`
+      (same shape as `new`'s inline form, but every node MUST carry `:uuid`)
+      into an existing artefact.
+
+  Every operation records its lineage in the result's `metadata.provenance`.
+
+  ## Exporting
+
+    * `Artefact.Arrows` — round-trip with [arrows.app](https://arrows.app)
+      via `from_json/2`, `from_json!/2`, `to_json/1`.
+    * `Artefact.Cypher` — derive Cypher (CREATE or MERGE) for Neo4j
+      persistence, with parameterised variants for driver use.
   """
 
-  defstruct [:id, :uuid, :title, :description, :base_label, :style, metadata: %{}, graph: %Artefact.Graph{}]
+  defstruct [
+    :id,
+    :uuid,
+    :title,
+    :description,
+    :base_label,
+    :style,
+    metadata: %{},
+    graph: %Artefact.Graph{}
+  ]
 
   @type t :: %__MODULE__{
           id: String.t(),
@@ -36,14 +67,20 @@ defmodule Artefact do
   """
   defmacro new(attrs \\ []) do
     caller = __CALLER__.module
-    caller_name = caller && (caller |> Module.split() |> List.last())
+    caller_name = caller && caller |> Module.split() |> List.last()
     default_base_label = caller_name && String.replace(caller_name, ~r/[^A-Za-z0-9]/, "")
+
     quote do
-      attrs      = unquote(attrs)
-      metadata   = %{provenance: %{source: :struct, module: unquote(caller)}}
-      title      = Keyword.get(attrs, :title, unquote(caller_name))
+      attrs = unquote(attrs)
+      metadata = %{provenance: %{source: :struct, module: unquote(caller)}}
+      title = Keyword.get(attrs, :title, unquote(caller_name))
       base_label = Keyword.get(attrs, :base_label, unquote(default_base_label))
-      Artefact.build([{:title, title}, {:base_label, base_label}, {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata])])
+
+      Artefact.build([
+        {:title, title},
+        {:base_label, base_label},
+        {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata])
+      ])
     end
   end
 
@@ -59,6 +96,7 @@ defmodule Artefact do
   """
   defmacro compose(a1, a2, opts \\ []) do
     caller = __CALLER__.module
+
     quote do
       Artefact.do_compose(unquote(a1), unquote(a2), unquote(opts), unquote(caller))
     end
@@ -67,11 +105,28 @@ defmodule Artefact do
   @doc false
   def do_compose(%__MODULE__{} = a1, %__MODULE__{} = a2, opts, caller) do
     base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label))
-    title      = Keyword.get(opts, :title, base_label)
-    graph      = merge_graphs(a1.graph, a2.graph)
-    metadata   = %{provenance: %{source: :composed, module: caller,
-                                left:  %{title: a1.title, base_label: a1.base_label, uuid: a1.uuid, provenance: Map.get(a1.metadata, :provenance)},
-                                right: %{title: a2.title, base_label: a2.base_label, uuid: a2.uuid, provenance: Map.get(a2.metadata, :provenance)}}}
+    title = Keyword.get(opts, :title, base_label)
+    graph = merge_graphs(a1.graph, a2.graph)
+
+    metadata = %{
+      provenance: %{
+        source: :composed,
+        module: caller,
+        left: %{
+          title: a1.title,
+          base_label: a1.base_label,
+          uuid: a1.uuid,
+          provenance: Map.get(a1.metadata, :provenance)
+        },
+        right: %{
+          title: a2.title,
+          base_label: a2.base_label,
+          uuid: a2.uuid,
+          provenance: Map.get(a2.metadata, :provenance)
+        }
+      }
+    }
+
     build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}])
   end
 
@@ -98,6 +153,7 @@ defmodule Artefact do
   """
   defmacro combine(heart, other, opts \\ []) do
     caller = __CALLER__.module
+
     quote do
       Artefact.do_combine(unquote(heart), unquote(other), unquote(opts), unquote(caller))
     end
@@ -114,6 +170,209 @@ defmodule Artefact do
     end
   end
 
+  @doc """
+  Graft `args` onto `left`, integrating new nodes and relationships
+  declared inline (same shape as `Artefact.new` accepts) without creating
+  a second artefact.
+
+  Designed for pipelines after a series of `combine`s — `args` flows in as
+  the second argument, with the result's `:title` and `:description` named
+  in `opts`:
+
+      our_shells_artefact
+      |> Artefact.combine(our_manifesto_artefact)
+      |> Artefact.graft(args, title: "Our Shells and Manifesto",
+           description: "Our Shells and Manifesto shape our Association Knowing.")
+
+  ## args
+
+  A keyword list with `:nodes` and `:relationships`, identical in shape to
+  what `Artefact.new` accepts inline — except that **every node entry must
+  carry an explicit `:uuid`**. There is no auto-find: the uuid is the
+  binding.
+
+  Each args node either:
+
+    * **Binds** to an existing left node (uuid present in `left.graph.nodes`).
+      Labels are unioned, properties merged with **left winning** on key
+      conflicts. Position is untouched.
+
+    * **Adds** a new node (uuid not in left). Receives a fresh sequential id
+      continuing left's offset.
+
+  Args relationships use args-local atom keys, just like `Artefact.new`.
+  Every key referenced by a relationship must be declared in `args.nodes`.
+
+  ## opts
+
+  Honours `:title` and `:description` only — both name the result. If
+  omitted, `left`'s title and description carry forward. `:base_label` is
+  **not** honoured; the result keeps `left.base_label`.
+
+  ## Raises
+
+    * `ArgumentError` — any args node missing `:uuid`
+    * `ArgumentError` — duplicate keys in `args.nodes`
+    * `ArgumentError` — a relationship references a key not in `args.nodes`
+
+  ## Provenance
+
+  Records `:grafted` with the calling module, a summary of `left`, and
+  `right: %{title: , description: }` — the
+  result's name as provided.
+  """
+  defmacro graft(left, args, opts \\ []) do
+    caller = __CALLER__.module
+
+    quote do
+      Artefact.do_graft(unquote(left), unquote(args), unquote(opts), unquote(caller))
+    end
+  end
+
+  @doc false
+  def do_graft(%__MODULE__{} = left, args, opts, caller) do
+    node_specs = Keyword.get(args, :nodes, [])
+    rel_specs = Keyword.get(args, :relationships, [])
+
+    validate_graft_node_uuids!(node_specs)
+    validate_graft_unique_keys!(node_specs)
+
+    left_by_uuid = Map.new(left.graph.nodes, &{&1.uuid, &1})
+
+    {bind_specs, new_specs} =
+      Enum.split_with(node_specs, fn {_key, node_opts} ->
+        Map.has_key?(left_by_uuid, Keyword.fetch!(node_opts, :uuid))
+      end)
+
+    bind_key_map =
+      Map.new(bind_specs, fn {key, node_opts} ->
+        existing = Map.fetch!(left_by_uuid, Keyword.fetch!(node_opts, :uuid))
+        {key, existing.id}
+      end)
+
+    offset = length(left.graph.nodes)
+
+    {new_nodes, new_key_map} =
+      new_specs
+      |> Enum.with_index(offset)
+      |> Enum.map_reduce(%{}, fn {{key, node_opts}, i}, acc ->
+        id = "n#{i}"
+
+        node = %Artefact.Node{
+          id: id,
+          uuid: Keyword.fetch!(node_opts, :uuid),
+          labels: Keyword.get(node_opts, :labels, []),
+          properties: Keyword.get(node_opts, :properties, %{}),
+          position: Keyword.get(node_opts, :position)
+        }
+
+        {node, Map.put(acc, key, id)}
+      end)
+
+    key_map = Map.merge(bind_key_map, new_key_map)
+
+    validate_graft_rel_keys!(rel_specs, key_map)
+
+    bind_updates =
+      Map.new(bind_specs, fn {_key, node_opts} ->
+        uuid = Keyword.fetch!(node_opts, :uuid)
+        existing = Map.fetch!(left_by_uuid, uuid)
+
+        merged = %{
+          existing
+          | labels: Enum.uniq(existing.labels ++ Keyword.get(node_opts, :labels, [])),
+            properties: Map.merge(Keyword.get(node_opts, :properties, %{}), existing.properties)
+        }
+
+        {uuid, merged}
+      end)
+
+    updated_left_nodes =
+      Enum.map(left.graph.nodes, fn n -> Map.get(bind_updates, n.uuid, n) end)
+
+    rel_offset = length(left.graph.relationships)
+
+    new_rels =
+      rel_specs
+      |> Enum.with_index(rel_offset)
+      |> Enum.map(fn {spec, i} ->
+        %Artefact.Relationship{
+          id: "r#{i}",
+          from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)),
+          to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)),
+          type: Keyword.fetch!(spec, :type),
+          properties: Keyword.get(spec, :properties, %{})
+        }
+      end)
+
+    relationships = deduplicate_rels(left.graph.relationships, new_rels)
+
+    graph = %Artefact.Graph{
+      nodes: updated_left_nodes ++ new_nodes,
+      relationships: relationships
+    }
+
+    title = Keyword.get(opts, :title, left.title)
+    description = Keyword.get(opts, :description, left.description)
+
+    metadata = %{
+      provenance: %{
+        source: :grafted,
+        module: caller,
+        left: %{
+          title: left.title,
+          base_label: left.base_label,
+          uuid: left.uuid,
+          provenance: Map.get(left.metadata, :provenance)
+        },
+        right: %{title: Keyword.get(opts, :title), description: Keyword.get(opts, :description)}
+      }
+    }
+
+    build([
+      {:title, title},
+      {:description, description},
+      {:base_label, left.base_label},
+      {:graph, graph},
+      {:metadata, metadata}
+    ])
+  end
+
+  defp validate_graft_node_uuids!(node_specs) do
+    Enum.each(node_specs, fn {key, node_opts} ->
+      case Keyword.fetch(node_opts, :uuid) do
+        {:ok, _} -> :ok
+        :error -> raise ArgumentError, "graft: node #{inspect(key)} is missing required :uuid"
+      end
+    end)
+  end
+
+  defp validate_graft_unique_keys!(node_specs) do
+    keys = Enum.map(node_specs, fn {k, _} -> k end)
+    dupes = keys -- Enum.uniq(keys)
+
+    if dupes != [] do
+      raise ArgumentError, "graft: duplicate node keys: #{inspect(Enum.uniq(dupes))}"
+    end
+  end
+
+  defp validate_graft_rel_keys!(rel_specs, key_map) do
+    Enum.each(rel_specs, fn spec ->
+      from = Keyword.fetch!(spec, :from)
+      to = Keyword.fetch!(spec, :to)
+
+      unless Map.has_key?(key_map, from) do
+        raise ArgumentError,
+              "graft: relationship references unknown node key #{inspect(from)} (not in args.nodes)"
+      end
+
+      unless Map.has_key?(key_map, to) do
+        raise ArgumentError,
+              "graft: relationship references unknown node key #{inspect(to)} (not in args.nodes)"
+      end
+    end)
+  end
+
   @doc """
   Harmonise two artefacts using declared bindings.
 
@@ -126,8 +385,15 @@ defmodule Artefact do
   """
   defmacro harmonise(a1, a2, bindings, opts \\ []) do
     caller = __CALLER__.module
+
     quote do
-      Artefact.do_harmonise(unquote(a1), unquote(a2), unquote(bindings), unquote(opts), unquote(caller))
+      Artefact.do_harmonise(
+        unquote(a1),
+        unquote(a2),
+        unquote(bindings),
+        unquote(opts),
+        unquote(caller)
+      )
     end
   end
 
@@ -138,18 +404,20 @@ defmodule Artefact do
     end
 
     if a1.base_label != nil and a1.base_label == a2.base_label do
-      raise ArgumentError, "cannot harmonise artefacts with the same base_label (#{a1.base_label})"
+      raise ArgumentError,
+            "cannot harmonise artefacts with the same base_label (#{a1.base_label})"
     end
 
     base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label))
-    title      = Keyword.get(opts, :title, base_label)
+    title = Keyword.get(opts, :title, base_label)
 
     nodes_a = Map.new(a1.graph.nodes, &{&1.uuid, &1})
     nodes_b = Map.new(a2.graph.nodes, &{&1.uuid, &1})
 
     # Resolve each binding: primary (lower uuid) absorbs secondary
     {primary_updates, b_id_remap} =
-      Enum.reduce(bindings, {%{}, %{}}, fn %Artefact.Binding{uuid_a: ua, uuid_b: ub}, {updates, remap} ->
+      Enum.reduce(bindings, {%{}, %{}}, fn %Artefact.Binding{uuid_a: ua, uuid_b: ub},
+                                           {updates, remap} ->
         node_a = nodes_a[ua]
         node_b = nodes_b[ub]
         surviving = Artefact.UUID.harmonise(ua, ub)
@@ -157,16 +425,17 @@ defmodule Artefact do
         {primary, secondary} =
           if surviving == ua, do: {node_a, node_b}, else: {node_b, node_a}
 
-        merged = %{primary |
-          labels:     Enum.uniq(node_a.labels ++ node_b.labels),
-          properties: Map.merge(node_b.properties, node_a.properties)
+        merged = %{
+          primary
+          | labels: Enum.uniq(node_a.labels ++ node_b.labels),
+            properties: Map.merge(node_b.properties, node_a.properties)
         }
 
         {Map.put(updates, primary.uuid, merged), Map.put(remap, secondary.id, primary.id)}
       end)
 
     bound_uuids_b = MapSet.new(bindings, & &1.uuid_b)
-    offset        = length(a1.graph.nodes)
+    offset = length(a1.graph.nodes)
 
     # Reindex a2 non-bound nodes to avoid id collision
     {b_nodes_reindexed, b_id_remap} =
@@ -178,32 +447,53 @@ defmodule Artefact do
         {acc ++ [%{node | id: new_id}], Map.put(remap, node.id, new_id)}
       end)
 
-    nodes_from_a = Enum.map(a1.graph.nodes, fn n ->
-      Map.get(primary_updates, n.uuid, n)
-    end)
+    nodes_from_a =
+      Enum.map(a1.graph.nodes, fn n ->
+        Map.get(primary_updates, n.uuid, n)
+      end)
 
-    rels_from_b = Enum.map(a2.graph.relationships, fn rel ->
-      %{rel | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id),
-              to_id:   Map.get(b_id_remap, rel.to_id,   rel.to_id)}
-    end)
+    rels_from_b =
+      Enum.map(a2.graph.relationships, fn rel ->
+        %{
+          rel
+          | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id),
+            to_id: Map.get(b_id_remap, rel.to_id, rel.to_id)
+        }
+      end)
 
     relationships = deduplicate_rels(a1.graph.relationships, rels_from_b)
 
     graph = %Artefact.Graph{
-      nodes:         nodes_from_a ++ b_nodes_reindexed,
+      nodes: nodes_from_a ++ b_nodes_reindexed,
       relationships: relationships
     }
 
-    metadata = %{provenance: %{source: :harmonised, module: caller,
-                               left:  %{title: a1.title, base_label: a1.base_label, uuid: a1.uuid, provenance: Map.get(a1.metadata, :provenance)},
-                               right: %{title: a2.title, base_label: a2.base_label, uuid: a2.uuid, provenance: Map.get(a2.metadata, :provenance)}}}
+    metadata = %{
+      provenance: %{
+        source: :harmonised,
+        module: caller,
+        left: %{
+          title: a1.title,
+          base_label: a1.base_label,
+          uuid: a1.uuid,
+          provenance: Map.get(a1.metadata, :provenance)
+        },
+        right: %{
+          title: a2.title,
+          base_label: a2.base_label,
+          uuid: a2.uuid,
+          provenance: Map.get(a2.metadata, :provenance)
+        }
+      }
+    }
+
     build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}])
   end
 
   @doc false
   def build(attrs) do
     {node_specs, attrs} = Keyword.pop(attrs, :nodes, [])
-    {rel_specs,  attrs} = Keyword.pop(attrs, :relationships, [])
+    {rel_specs, attrs} = Keyword.pop(attrs, :relationships, [])
 
     attrs =
       if node_specs != [] or rel_specs != [] do
@@ -212,7 +502,10 @@ defmodule Artefact do
         attrs
       end
 
-    struct!(__MODULE__, [{:id, Artefact.UUID.generate_v7()}, {:uuid, Artefact.UUID.generate_v7()} | attrs])
+    struct!(__MODULE__, [
+      {:id, Artefact.UUID.generate_v7()},
+      {:uuid, Artefact.UUID.generate_v7()} | attrs
+    ])
   end
 
   defp build_graph(node_specs, rel_specs) do
@@ -220,14 +513,16 @@ defmodule Artefact do
       node_specs
       |> Enum.with_index()
       |> Enum.map_reduce(%{}, fn {{key, opts}, i}, acc ->
-        id   = "n#{i}"
+        id = "n#{i}"
+
         node = %Artefact.Node{
-          id:         id,
-          uuid:       Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()),
-          labels:     Keyword.get(opts, :labels, []),
+          id: id,
+          uuid: Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()),
+          labels: Keyword.get(opts, :labels, []),
           properties: Keyword.get(opts, :properties, %{}),
-          position:   Keyword.get(opts, :position)
+          position: Keyword.get(opts, :position)
         }
+
         {node, Map.put(acc, key, id)}
       end)
 
@@ -236,10 +531,10 @@ defmodule Artefact do
       |> Enum.with_index()
       |> Enum.map(fn {spec, i} ->
         %Artefact.Relationship{
-          id:         "r#{i}",
-          from_id:    Map.fetch!(key_map, Keyword.fetch!(spec, :from)),
-          to_id:      Map.fetch!(key_map, Keyword.fetch!(spec, :to)),
-          type:       Keyword.fetch!(spec, :type),
+          id: "r#{i}",
+          from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)),
+          to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)),
+          type: Keyword.fetch!(spec, :type),
           properties: Keyword.get(spec, :properties, %{})
         }
       end)
@@ -253,9 +548,14 @@ defmodule Artefact do
     merged_index =
       Enum.reduce(rels_b, index, fn rel, acc ->
         key = {rel.from_id, rel.type, rel.to_id}
+
         case Map.fetch(acc, key) do
           {:ok, existing} ->
-            Map.put(acc, key, %{existing | properties: Map.merge(rel.properties, existing.properties)})
+            Map.put(acc, key, %{
+              existing
+              | properties: Map.merge(rel.properties, existing.properties)
+            })
+
           :error ->
             Map.put(acc, key, rel)
         end
@@ -267,25 +567,26 @@ defmodule Artefact do
   defp merge_graphs(g1, g2) do
     offset = length(g1.nodes)
 
-    id_map = g2.nodes
+    id_map =
+      g2.nodes
       |> Enum.with_index(offset)
       |> Map.new(fn {node, i} -> {node.id, "n#{i}"} end)
 
     nodes =
       g1.nodes ++
-      Enum.map(g2.nodes, fn node -> %{node | id: id_map[node.id]} end)
+        Enum.map(g2.nodes, fn node -> %{node | id: id_map[node.id]} end)
 
     rels =
       g1.relationships ++
-      Enum.map(g2.relationships, fn rel ->
-        %{rel | from_id: id_map[rel.from_id], to_id: id_map[rel.to_id]}
-      end)
+        Enum.map(g2.relationships, fn rel ->
+          %{rel | from_id: id_map[rel.from_id], to_id: id_map[rel.to_id]}
+        end)
 
     %Artefact.Graph{nodes: nodes, relationships: rels}
   end
 
   defp portmanteau(nil, nil), do: nil
-  defp portmanteau(a, nil),   do: a
-  defp portmanteau(nil, b),   do: b
-  defp portmanteau(a, b),     do: a <> b
+  defp portmanteau(a, nil), do: a
+  defp portmanteau(nil, b), do: b
+  defp portmanteau(a, b), do: a <> b
 end
diff --git a/artefact/lib/artefact/arrows.ex b/artefact/lib/artefact/arrows.ex
index 1853025..e31af60 100644
--- a/artefact/lib/artefact/arrows.ex
+++ b/artefact/lib/artefact/arrows.ex
@@ -35,6 +35,7 @@ defmodule Artefact.Arrows do
 
   defp decode(raw, opts) do
     base_label = Keyword.get(opts, :base_label, Map.get(raw, "base_label"))
+
     nodes =
       raw
       |> Map.get("nodes", [])
@@ -43,9 +44,10 @@ defmodule Artefact.Arrows do
     relationships = raw |> Map.get("relationships", []) |> Enum.map(&decode_relationship/1)
     graph = %Artefact.Graph{nodes: nodes, relationships: relationships}
 
-    metadata = Keyword.get(opts, :metadata,
-      %{provenance: %{source: :arrows_json, diagram: Keyword.get(opts, :diagram)}}
-    )
+    metadata =
+      Keyword.get(opts, :metadata, %{
+        provenance: %{source: :arrows_json, diagram: Keyword.get(opts, :diagram)}
+      })
 
     %Artefact{
       id: Keyword.get(opts, :id, Artefact.UUID.generate_v7()),
@@ -88,7 +90,13 @@ defmodule Artefact.Arrows do
 
   # -- encode --
 
-  defp encode(%Artefact{uuid: uuid, title: title, description: description, base_label: base_label, graph: graph}) do
+  defp encode(%Artefact{
+         uuid: uuid,
+         title: title,
+         description: description,
+         base_label: base_label,
+         graph: graph
+       }) do
     %{
       "uuid" => uuid,
       "title" => title,
@@ -128,5 +136,4 @@ defmodule Artefact.Arrows do
       "style" => %{}
     }
   end
-
 end
diff --git a/artefact/lib/artefact/cypher.ex b/artefact/lib/artefact/cypher.ex
index 64a0459..084e077 100644
--- a/artefact/lib/artefact/cypher.ex
+++ b/artefact/lib/artefact/cypher.ex
@@ -37,7 +37,7 @@ defmodule Artefact.Cypher do
     rel_stmts =
       Enum.map(graph.relationships, fn rel ->
         from = Enum.find(graph.nodes, &(&1.id == rel.from_id))
-        to   = Enum.find(graph.nodes, &(&1.id == rel.to_id))
+        to = Enum.find(graph.nodes, &(&1.id == rel.to_id))
         inline_merge_rel_stmt(rel, from, to)
       end)
 
@@ -78,7 +78,7 @@ defmodule Artefact.Cypher do
       |> Enum.with_index()
       |> Enum.map(fn {rel, idx} ->
         from = Enum.find(graph.nodes, &(&1.id == rel.from_id))
-        to   = Enum.find(graph.nodes, &(&1.id == rel.to_id))
+        to = Enum.find(graph.nodes, &(&1.id == rel.to_id))
         params_merge_rel_stmt(rel, from, to, idx)
       end)
       |> Enum.unzip()
@@ -91,11 +91,14 @@ defmodule Artefact.Cypher do
 
   # -- inline (browser) merge helpers --
 
-  defp inline_merge_node_stmt(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+  defp inline_merge_node_stmt(
+         %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+         base_label
+       ) do
     effective = effective_labels(labels, base_label)
     label_str = Enum.map_join(effective, "", &":#{&1}")
     set_labels = if label_str != "", do: "\nSET #{id}#{label_str}", else: ""
-    set_props  = if map_size(props) > 0, do: "\nSET #{id} += #{props_to_cypher(props)}", else: ""
+    set_props = if map_size(props) > 0, do: "\nSET #{id} += #{props_to_cypher(props)}", else: ""
     "MERGE (#{id} {uuid: '#{uuid}'})#{set_labels}#{set_props}"
   end
 
@@ -109,7 +112,10 @@ defmodule Artefact.Cypher do
 
   # -- parameterised create helpers --
 
-  defp params_node_pattern(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+  defp params_node_pattern(
+         %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+         base_label
+       ) do
     label_str = labels |> effective_labels(base_label) |> Enum.map_join("", &":#{&1}")
     all_props = Map.put(props, "uuid", uuid)
 
@@ -126,20 +132,29 @@ defmodule Artefact.Cypher do
 
   # -- parameterised merge helpers --
 
-  defp params_merge_node_stmt(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+  defp params_merge_node_stmt(
+         %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+         base_label
+       ) do
     label_str = labels |> effective_labels(base_label) |> Enum.map_join("", &":#{&1}")
     set_labels = if label_str != "", do: "\nSET #{id}#{label_str}", else: ""
-    set_props  = if map_size(props) > 0, do: "\nSET #{id} += $#{id}_props", else: ""
+    set_props = if map_size(props) > 0, do: "\nSET #{id} += $#{id}_props", else: ""
+
+    stmt = "MERGE (#{id} {uuid: $#{id}_uuid})#{set_labels}#{set_props}"
+
+    params =
+      Map.merge(
+        %{"#{id}_uuid" => uuid},
+        if(map_size(props) > 0, do: %{"#{id}_props" => props}, else: %{})
+      )
 
-    stmt   = "MERGE (#{id} {uuid: $#{id}_uuid})#{set_labels}#{set_props}"
-    params = Map.merge(%{"#{id}_uuid" => uuid}, if(map_size(props) > 0, do: %{"#{id}_props" => props}, else: %{}))
     {stmt, params}
   end
 
   defp params_merge_rel_stmt(%Artefact.Relationship{type: type, properties: props}, from, to, idx) do
     if map_size(props) > 0 do
-      rvar   = "r#{idx}"
-      stmt   = "MERGE (#{from.id})-[#{rvar}:#{type}]->(#{to.id})\nSET #{rvar} += $#{rvar}_props"
+      rvar = "r#{idx}"
+      stmt = "MERGE (#{from.id})-[#{rvar}:#{type}]->(#{to.id})\nSET #{rvar} += $#{rvar}_props"
       params = %{"#{rvar}_props" => props}
       {stmt, params}
     else
@@ -147,7 +162,10 @@ defmodule Artefact.Cypher do
     end
   end
 
-  defp node_pattern(%Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props}, base_label) do
+  defp node_pattern(
+         %Artefact.Node{id: id, uuid: uuid, labels: labels, properties: props},
+         base_label
+       ) do
     label_str = labels |> effective_labels(base_label) |> Enum.map_join("", &":#{&1}")
     prop_str = props_to_cypher(Map.put(props, "uuid", uuid))
     "(#{id}#{label_str} #{prop_str})"
@@ -195,5 +213,4 @@ defmodule Artefact.Cypher do
   defp cypher_value(true), do: "true"
   defp cypher_value(false), do: "false"
   defp cypher_value(nil), do: "null"
-
 end
diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex
index 2bda4d6..318cda6 100644
--- a/artefact/lib/artefact/mermaid.ex
+++ b/artefact/lib/artefact/mermaid.ex
@@ -117,7 +117,23 @@ defmodule Artefact.Mermaid do
 
   defp needs_yaml_quoting?(s) do
     String.contains?(s, [":", "\"", "#", "\n"]) or
-      String.starts_with?(s, [" ", "\t", "&", "*", "!", "?", "{", "[", "|", ">", "%", "@", "`", "'", "-"])
+      String.starts_with?(s, [
+        " ",
+        "\t",
+        "&",
+        "*",
+        "!",
+        "?",
+        "{",
+        "[",
+        "|",
+        ">",
+        "%",
+        "@",
+        "`",
+        "'",
+        "-"
+      ])
   end
 
   # accTitle / accDescr inline form is single-line; collapse any newlines to spaces.
diff --git a/artefact/mix.exs b/artefact/mix.exs
index d96965b..a3ed5e3 100644
--- a/artefact/mix.exs
+++ b/artefact/mix.exs
@@ -5,7 +5,7 @@ defmodule Artefact.MixProject do
   @moduledoc false
   use Mix.Project
 
-  @version "0.1.3"
+  @version "0.1.4"
   @github_url "https://github.com/diffo-dev/artefactory"
 
   def project do
@@ -14,6 +14,7 @@ defmodule Artefact.MixProject do
       version: @version,
       elixir: "~> 1.16",
       start_permanent: Mix.env() == :prod,
+      elixirc_paths: elixirc_paths(Mix.env()),
       deps: deps(),
       package: package(),
       name: "Artefact",
@@ -23,6 +24,9 @@ defmodule Artefact.MixProject do
     ]
   end
 
+  defp elixirc_paths(:test), do: ["lib", "test/support"]
+  defp elixirc_paths(_),     do: ["lib"]
+
   def application do
     [extra_applications: [:logger]]
   end
@@ -47,7 +51,11 @@ defmodule Artefact.MixProject do
       main: "Artefact",
       source_url: @github_url,
       source_ref: "v#{@version}",
-      extras: ["README.md", "CHANGELOG.md"]
+      extras: [
+        "README.md",
+        "CHANGELOG.md",
+        {"LICENSES/MIT.txt", title: "License (MIT)"}
+      ]
     ]
   end
 end
diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs
index 64f151f..c426387 100644
--- a/artefact/test/artefact_test.exs
+++ b/artefact/test/artefact_test.exs
@@ -7,8 +7,16 @@ defmodule ArtefactTest do
 
   @fixtures Path.join(__DIR__, "data")
 
-  defp shared_node, do: %Artefact.Node{id: "n0", uuid: "019d0000-0000-7000-8000-000000000000", labels: ["Shared"], properties: %{}}
-  defp other_node(uuid), do: %Artefact.Node{id: "n1", uuid: uuid, labels: ["Other"], properties: %{}}
+  defp shared_node,
+    do: %Artefact.Node{
+      id: "n0",
+      uuid: "019d0000-0000-7000-8000-000000000000",
+      labels: ["Shared"],
+      properties: %{}
+    }
+
+  defp other_node(uuid),
+    do: %Artefact.Node{id: "n1", uuid: uuid, labels: ["Other"], properties: %{}}
 
   defp artefact_with(nodes) do
     Artefact.new(graph: %Artefact.Graph{nodes: nodes, relationships: []})
@@ -36,47 +44,93 @@ defmodule ArtefactTest do
       a1 = Artefact.new()
       a2 = Artefact.new()
       composed = Artefact.compose(a1, a2)
-      assert %{provenance: %{source: :composed, module: ArtefactTest,
-                             left:  %{title: left_title,  base_label: left_bl,  uuid: left_uuid,  provenance: left_prov},
-                             right: %{title: right_title, base_label: right_bl, uuid: right_uuid, provenance: right_prov}}} = composed.metadata
-      assert left_title  == a1.title
-      assert left_bl     == a1.base_label
-      assert left_uuid   == a1.uuid
+
+      assert %{
+               provenance: %{
+                 source: :composed,
+                 module: ArtefactTest,
+                 left: %{
+                   title: left_title,
+                   base_label: left_bl,
+                   uuid: left_uuid,
+                   provenance: left_prov
+                 },
+                 right: %{
+                   title: right_title,
+                   base_label: right_bl,
+                   uuid: right_uuid,
+                   provenance: right_prov
+                 }
+               }
+             } = composed.metadata
+
+      assert left_title == a1.title
+      assert left_bl == a1.base_label
+      assert left_uuid == a1.uuid
       assert right_title == a2.title
-      assert right_bl    == a2.base_label
-      assert right_uuid  == a2.uuid
-      assert left_prov   == a1.metadata.provenance
-      assert right_prov  == a2.metadata.provenance
+      assert right_bl == a2.base_label
+      assert right_uuid == a2.uuid
+      assert left_prov == a1.metadata.provenance
+      assert right_prov == a2.metadata.provenance
     end
 
     test "harmonise records :harmonised provenance with left and right title, base_label, uuid and provenance" do
-      a1 = Artefact.new(base_label: "LeftArtefact", graph: %Artefact.Graph{nodes: [shared_node()], relationships: []})
-      a2 = Artefact.new(base_label: "RightArtefact", graph: %Artefact.Graph{nodes: [shared_node()], relationships: []})
+      a1 =
+        Artefact.new(
+          base_label: "LeftArtefact",
+          graph: %Artefact.Graph{nodes: [shared_node()], relationships: []}
+        )
+
+      a2 =
+        Artefact.new(
+          base_label: "RightArtefact",
+          graph: %Artefact.Graph{nodes: [shared_node()], relationships: []}
+        )
+
       {:ok, bindings} = Artefact.Binding.find(a1, a2)
       result = Artefact.harmonise(a1, a2, bindings)
-      assert %{provenance: %{source: :harmonised, module: ArtefactTest,
-                             left:  %{title: left_title,  base_label: left_bl,  uuid: left_uuid,  provenance: left_prov},
-                             right: %{title: right_title, base_label: right_bl, uuid: right_uuid, provenance: right_prov}}} = result.metadata
-      assert left_title  == a1.title
-      assert left_bl     == a1.base_label
-      assert left_uuid   == a1.uuid
+
+      assert %{
+               provenance: %{
+                 source: :harmonised,
+                 module: ArtefactTest,
+                 left: %{
+                   title: left_title,
+                   base_label: left_bl,
+                   uuid: left_uuid,
+                   provenance: left_prov
+                 },
+                 right: %{
+                   title: right_title,
+                   base_label: right_bl,
+                   uuid: right_uuid,
+                   provenance: right_prov
+                 }
+               }
+             } = result.metadata
+
+      assert left_title == a1.title
+      assert left_bl == a1.base_label
+      assert left_uuid == a1.uuid
       assert right_title == a2.title
-      assert right_bl    == a2.base_label
-      assert right_uuid  == a2.uuid
-      assert left_prov   == a1.metadata.provenance
-      assert right_prov  == a2.metadata.provenance
+      assert right_bl == a2.base_label
+      assert right_uuid == a2.uuid
+      assert left_prov == a1.metadata.provenance
+      assert right_prov == a2.metadata.provenance
     end
   end
 
   describe "Artefact.new/1 — inline nodes and relationships" do
     test "builds nodes with sequential ids" do
-      a = Artefact.new(
-        nodes: [
-          matt:   [labels: ["Agent", "Me"],  properties: %{"name" => "Matt"}],
-          claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
-        ],
-        relationships: []
-      )
+      a =
+        Artefact.new(
+          nodes: [
+            matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}],
+            claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
+          ],
+          relationships: []
+        )
+
       by_id = Map.new(a.graph.nodes, &{&1.id, &1})
       assert map_size(by_id) == 2
       assert Map.has_key?(by_id, "n0")
@@ -84,13 +138,15 @@ defmodule ArtefactTest do
     end
 
     test "nodes have correct labels and properties" do
-      a = Artefact.new(
-        nodes: [
-          matt:   [labels: ["Agent", "Me"],  properties: %{"name" => "Matt"}],
-          claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
-        ],
-        relationships: []
-      )
+      a =
+        Artefact.new(
+          nodes: [
+            matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}],
+            claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}]
+          ],
+          relationships: []
+        )
+
       by_id = Map.new(a.graph.nodes, &{&1.id, &1})
       assert by_id["n0"].labels == ["Agent", "Me"]
       assert by_id["n0"].properties == %{"name" => "Matt"}
@@ -113,35 +169,41 @@ defmodule ArtefactTest do
     end
 
     test "builds relationship resolving atom keys to ids" do
-      a = Artefact.new(
-        nodes: [
-          matt:   [labels: ["Agent"]],
-          claude: [labels: ["Agent"]]
-        ],
-        relationships: [
-          [from: :matt, type: "US_TWO", to: :claude]
-        ]
-      )
+      a =
+        Artefact.new(
+          nodes: [
+            matt: [labels: ["Agent"]],
+            claude: [labels: ["Agent"]]
+          ],
+          relationships: [
+            [from: :matt, type: "US_TWO", to: :claude]
+          ]
+        )
+
       [rel] = a.graph.relationships
       assert rel.from_id == "n0"
-      assert rel.to_id   == "n1"
-      assert rel.type    == "US_TWO"
+      assert rel.to_id == "n1"
+      assert rel.type == "US_TWO"
     end
 
     test "relationship properties default to empty map" do
-      a = Artefact.new(
-        nodes: [a: [labels: []], b: [labels: []]],
-        relationships: [[from: :a, type: "KNOWS", to: :b]]
-      )
+      a =
+        Artefact.new(
+          nodes: [a: [labels: []], b: [labels: []]],
+          relationships: [[from: :a, type: "KNOWS", to: :b]]
+        )
+
       [rel] = a.graph.relationships
       assert rel.properties == %{}
     end
 
     test "relationship properties are set when provided" do
-      a = Artefact.new(
-        nodes: [a: [labels: []], b: [labels: []]],
-        relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2024"}]]
-      )
+      a =
+        Artefact.new(
+          nodes: [a: [labels: []], b: [labels: []]],
+          relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2024"}]]
+        )
+
       [rel] = a.graph.relationships
       assert rel.properties == %{"since" => "2024"}
     end
@@ -160,13 +222,15 @@ defmodule ArtefactTest do
 
   describe "Artefact.new/1 — inline nodes and relationships — multiple relationships" do
     setup do
-      a = Artefact.new(
-        nodes: [x: [labels: ["X"]], y: [labels: ["Y"]], z: [labels: ["Z"]]],
-        relationships: [
-          [from: :x, type: "NEXT", to: :y],
-          [from: :y, type: "NEXT", to: :z]
-        ]
-      )
+      a =
+        Artefact.new(
+          nodes: [x: [labels: ["X"]], y: [labels: ["Y"]], z: [labels: ["Z"]]],
+          relationships: [
+            [from: :x, type: "NEXT", to: :y],
+            [from: :y, type: "NEXT", to: :z]
+          ]
+        )
+
       %{artefact: a}
     end
 
@@ -194,30 +258,37 @@ defmodule ArtefactTest do
       json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"]))
       from_json = Artefact.Arrows.from_json!(json)
 
-      from_struct = Artefact.new(
-        title: "UsTwo",
-        base_label: "UsTwo",
-        nodes: [
-          matt:   [labels: ["Agent", "Me"],  properties: %{"name" => "Matt"},
-                   uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"],
-          claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"},
-                   uuid: "019da897-f2de-768c-94e2-3005f2431f37"]
-        ],
-        relationships: [
-          [from: :matt, type: "US_TWO", to: :claude]
-        ]
-      )
+      from_struct =
+        Artefact.new(
+          title: "UsTwo",
+          base_label: "UsTwo",
+          nodes: [
+            matt: [
+              labels: ["Agent", "Me"],
+              properties: %{"name" => "Matt"},
+              uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"
+            ],
+            claude: [
+              labels: ["Agent", "You"],
+              properties: %{"name" => "Claude"},
+              uuid: "019da897-f2de-768c-94e2-3005f2431f37"
+            ]
+          ],
+          relationships: [
+            [from: :matt, type: "US_TWO", to: :claude]
+          ]
+        )
 
       %{from_json: from_json, from_struct: from_struct}
     end
 
     test "same title and base_label", %{from_json: j, from_struct: s} do
-      assert s.title      == j.title
+      assert s.title == j.title
       assert s.base_label == j.base_label
     end
 
     test "same number of nodes and relationships", %{from_json: j, from_struct: s} do
-      assert length(s.graph.nodes)         == length(j.graph.nodes)
+      assert length(s.graph.nodes) == length(j.graph.nodes)
       assert length(s.graph.relationships) == length(j.graph.relationships)
     end
 
@@ -242,7 +313,7 @@ defmodule ArtefactTest do
       assert sr.type == jr.type
       from_uuid = fn a, rel_id -> Enum.find(a.graph.nodes, &(&1.id == rel_id)).uuid end
       assert from_uuid.(s, sr.from_id) == from_uuid.(j, jr.from_id)
-      assert from_uuid.(s, sr.to_id)   == from_uuid.(j, jr.to_id)
+      assert from_uuid.(s, sr.to_id) == from_uuid.(j, jr.to_id)
     end
 
     test "inline build has :struct provenance", %{from_struct: s} do
@@ -328,7 +399,9 @@ defmodule ArtefactTest do
     test "to_json/from_json! preserves nodes and relationships" do
       json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"]))
       original = Artefact.Arrows.from_json!(json, id: "rt-test")
-      round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!(id: "rt-test")
+
+      round_tripped =
+        original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!(id: "rt-test")
 
       assert length(round_tripped.graph.nodes) == length(original.graph.nodes)
       assert length(round_tripped.graph.relationships) == length(original.graph.relationships)
@@ -351,7 +424,9 @@ defmodule ArtefactTest do
       a1 = artefact_with([shared_node(), other_node("019d0000-0000-7000-8000-000000000001")])
       a2 = artefact_with([shared_node(), other_node("019d0000-0000-7000-8000-000000000002")])
 
-      assert {:ok, [%Artefact.Binding{uuid_a: uuid, uuid_b: uuid}]} = Artefact.Binding.find(a1, a2)
+      assert {:ok, [%Artefact.Binding{uuid_a: uuid, uuid_b: uuid}]} =
+               Artefact.Binding.find(a1, a2)
+
       assert uuid == shared_node().uuid
     end
 
@@ -392,8 +467,12 @@ defmodule ArtefactTest do
 
     defp artefact_nodes(nodes) do
       %Artefact{
-        id: Artefact.UUID.generate_v7(), uuid: Artefact.UUID.generate_v7(),
-        title: nil, base_label: nil, style: nil, metadata: %{},
+        id: Artefact.UUID.generate_v7(),
+        uuid: Artefact.UUID.generate_v7(),
+        title: nil,
+        base_label: nil,
+        style: nil,
+        metadata: %{},
         graph: %Artefact.Graph{nodes: nodes, relationships: []}
       }
     end
@@ -443,8 +522,20 @@ defmodule ArtefactTest do
     end
 
     test "shared label appears once in union" do
-      n_a = %Artefact.Node{id: "n0", uuid: @uuid_shared, labels: ["Shared", "OnlyA"], properties: %{}}
-      n_b = %Artefact.Node{id: "n0", uuid: @uuid_shared, labels: ["Shared", "OnlyB"], properties: %{}}
+      n_a = %Artefact.Node{
+        id: "n0",
+        uuid: @uuid_shared,
+        labels: ["Shared", "OnlyA"],
+        properties: %{}
+      }
+
+      n_b = %Artefact.Node{
+        id: "n0",
+        uuid: @uuid_shared,
+        labels: ["Shared", "OnlyB"],
+        properties: %{}
+      }
+
       a1 = artefact_nodes([n_a])
       a2 = artefact_nodes([n_b])
       {:ok, bindings} = Artefact.Binding.find(a1, a2)
@@ -458,6 +549,7 @@ defmodule ArtefactTest do
     test "raises when harmonising an artefact with itself" do
       a = artefact_with([shared_node()])
       {:ok, bindings} = Artefact.Binding.find(a, a)
+
       assert_raise ArgumentError, ~r/cannot harmonise an artefact with itself/, fn ->
         Artefact.harmonise(a, a, bindings)
       end
@@ -466,6 +558,7 @@ defmodule ArtefactTest do
     test "raises when both artefacts have the same base_label" do
       a1 = Artefact.new(base_label: "Same")
       a2 = Artefact.new(base_label: "Same")
+
       assert_raise ArgumentError, ~r/cannot harmonise artefacts with the same base_label/, fn ->
         Artefact.harmonise(a1, a2, [])
       end
@@ -481,48 +574,125 @@ defmodule ArtefactTest do
         %Artefact.Node{id: id_x, uuid: uuid_x, labels: [], properties: %{}},
         %Artefact.Node{id: id_y, uuid: uuid_y, labels: [], properties: %{}}
       ]
+
       %Artefact{
-        id: Artefact.UUID.generate_v7(), uuid: Artefact.UUID.generate_v7(),
-        title: nil, base_label: nil, style: nil, metadata: %{},
+        id: Artefact.UUID.generate_v7(),
+        uuid: Artefact.UUID.generate_v7(),
+        title: nil,
+        base_label: nil,
+        style: nil,
+        metadata: %{},
         graph: %Artefact.Graph{nodes: nodes, relationships: rels}
       }
     end
 
     test "identical relationship appears once after harmonise" do
-      a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
-      a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
+      a1 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r0",
+            from_id: "n0",
+            to_id: "n1",
+            type: "KNOWS",
+            properties: %{}
+          }
+        ])
+
+      a2 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r0",
+            from_id: "n0",
+            to_id: "n1",
+            type: "KNOWS",
+            properties: %{}
+          }
+        ])
+
       {:ok, bindings} = Artefact.Binding.find(a1, a2)
       result = Artefact.harmonise(a1, a2, bindings)
       assert length(result.graph.relationships) == 1
     end
 
     test "different type relationships both survive" do
-      a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
-      a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r1", from_id: "n0", to_id: "n1", type: "TRUSTS", properties: %{}}])
+      a1 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r0",
+            from_id: "n0",
+            to_id: "n1",
+            type: "KNOWS",
+            properties: %{}
+          }
+        ])
+
+      a2 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r1",
+            from_id: "n0",
+            to_id: "n1",
+            type: "TRUSTS",
+            properties: %{}
+          }
+        ])
+
       {:ok, bindings} = Artefact.Binding.find(a1, a2)
       result = Artefact.harmonise(a1, a2, bindings)
       assert length(result.graph.relationships) == 2
     end
 
     test "opposite direction relationships both survive" do
-      a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{}}])
-      a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r1", from_id: "n1", to_id: "n0", type: "KNOWS", properties: %{}}])
+      a1 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r0",
+            from_id: "n0",
+            to_id: "n1",
+            type: "KNOWS",
+            properties: %{}
+          }
+        ])
+
+      a2 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r1",
+            from_id: "n1",
+            to_id: "n0",
+            type: "KNOWS",
+            properties: %{}
+          }
+        ])
+
       {:ok, bindings} = Artefact.Binding.find(a1, a2)
       result = Artefact.harmonise(a1, a2, bindings)
       assert length(result.graph.relationships) == 2
     end
 
     test "duplicate relationship properties merged left-wins" do
-      a1 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r0", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{"since" => "2020", "trust" => "high"}}])
-      a2 = two_node_artefact(@uuid_a, @uuid_b, "n0", "n1",
-        [%Artefact.Relationship{id: "r1", from_id: "n0", to_id: "n1", type: "KNOWS", properties: %{"since" => "2019", "source" => "intro"}}])
+      a1 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r0",
+            from_id: "n0",
+            to_id: "n1",
+            type: "KNOWS",
+            properties: %{"since" => "2020", "trust" => "high"}
+          }
+        ])
+
+      a2 =
+        two_node_artefact(@uuid_a, @uuid_b, "n0", "n1", [
+          %Artefact.Relationship{
+            id: "r1",
+            from_id: "n0",
+            to_id: "n1",
+            type: "KNOWS",
+            properties: %{"since" => "2019", "source" => "intro"}
+          }
+        ])
+
       {:ok, bindings} = Artefact.Binding.find(a1, a2)
       result = Artefact.harmonise(a1, a2, bindings)
       [rel] = result.graph.relationships
@@ -553,7 +723,9 @@ defmodule ArtefactTest do
   describe "Artefact.Cypher.create/1 — us_two" do
     test "matches fixture" do
       json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"]))
-      expected = File.read!(Path.join([@fixtures, "us_two", "create_cypher.txt"])) |> String.trim()
+
+      expected =
+        File.read!(Path.join([@fixtures, "us_two", "create_cypher.txt"])) |> String.trim()
 
       artefact = Artefact.Arrows.from_json!(json)
       assert Artefact.Cypher.create(artefact) == expected
@@ -613,7 +785,11 @@ defmodule ArtefactTest do
       end)
     end
 
-    test "node properties are in params not inline", %{artefact: a, cypher: cypher, params: params} do
+    test "node properties are in params not inline", %{
+      artefact: a,
+      cypher: cypher,
+      params: params
+    } do
       Enum.each(a.graph.nodes, fn node ->
         assert String.contains?(cypher, "$#{node.id}_props")
         assert params["#{node.id}_props"] == node.properties
@@ -639,7 +815,11 @@ defmodule ArtefactTest do
       refute String.contains?(cypher, "MERGE")
     end
 
-    test "node properties are in params not inline", %{artefact: a, cypher: cypher, params: params} do
+    test "node properties are in params not inline", %{
+      artefact: a,
+      cypher: cypher,
+      params: params
+    } do
       Enum.each(a.graph.nodes, fn node ->
         Enum.each(node.properties, fn {k, v} ->
           assert String.contains?(cypher, "$#{node.id}_#{k}")
@@ -673,7 +853,9 @@ defmodule ArtefactTest do
     end
 
     test "Cypher export matches fixture", %{artefact: a} do
-      expected = File.read!(Path.join([@fixtures, "artefact", "create_cypher.txt"])) |> String.trim()
+      expected =
+        File.read!(Path.join([@fixtures, "artefact", "create_cypher.txt"])) |> String.trim()
+
       assert Artefact.Cypher.create(a) == expected
     end
   end
@@ -707,12 +889,18 @@ defmodule ArtefactTest do
     end
 
     test "create Cypher matches fixture", %{artefact: a} do
-      expected = File.read!(Path.join([@fixtures, "artefact_harmonise", "create_cypher.txt"])) |> String.trim()
+      expected =
+        File.read!(Path.join([@fixtures, "artefact_harmonise", "create_cypher.txt"]))
+        |> String.trim()
+
       assert Artefact.Cypher.create(a) == expected
     end
 
     test "merge Cypher matches fixture", %{artefact: a} do
-      expected = File.read!(Path.join([@fixtures, "artefact_harmonise", "merge_cypher.txt"])) |> String.trim()
+      expected =
+        File.read!(Path.join([@fixtures, "artefact_harmonise", "merge_cypher.txt"]))
+        |> String.trim()
+
       assert Artefact.Cypher.merge(a) == expected
     end
   end
@@ -743,12 +931,18 @@ defmodule ArtefactTest do
     end
 
     test "create Cypher matches fixture", %{artefact: a} do
-      expected = File.read!(Path.join([@fixtures, "artefact_combine", "create_cypher.txt"])) |> String.trim()
+      expected =
+        File.read!(Path.join([@fixtures, "artefact_combine", "create_cypher.txt"]))
+        |> String.trim()
+
       assert Artefact.Cypher.create(a) == expected
     end
 
     test "merge Cypher matches fixture", %{artefact: a} do
-      expected = File.read!(Path.join([@fixtures, "artefact_combine", "merge_cypher.txt"])) |> String.trim()
+      expected =
+        File.read!(Path.join([@fixtures, "artefact_combine", "merge_cypher.txt"]))
+        |> String.trim()
+
       assert Artefact.Cypher.merge(a) == expected
     end
   end
@@ -1090,4 +1284,310 @@ defmodule ArtefactTest do
       assert_raise MatchError, fn -> Artefact.combine(heart, other) end
     end
   end
+
+  describe "Artefact.graft/3 — happy path with OurShells fixture" do
+    alias Artefact.Test.Fixtures.OurShells
+
+    setup do
+      left = OurShells.our_shells()
+
+      result =
+        Artefact.graft(left, OurShells.manifesto_args(),
+          title: "Our Shells and Manifesto",
+          description: "Our Shells and Manifesto shape our Association Knowing."
+        )
+
+      %{left: left, result: result}
+    end
+
+    test "result has opts title and description", %{result: r} do
+      assert r.title == "Our Shells and Manifesto"
+      assert r.description == "Our Shells and Manifesto shape our Association Knowing."
+    end
+
+    test "result keeps left base_label", %{left: left, result: r} do
+      assert r.base_label == left.base_label
+    end
+
+    test "result is a fresh artefact (new uuid)", %{left: left, result: r} do
+      assert r.uuid != left.uuid
+    end
+
+    test "new args nodes are appended to left graph", %{left: left, result: r} do
+      assert length(r.graph.nodes) == length(left.graph.nodes) + 3
+
+      uuids = Enum.map(r.graph.nodes, & &1.uuid)
+      assert OurShells.ethics_uuid() in uuids
+      assert OurShells.stewardship_uuid() in uuids
+      assert OurShells.intent_uuid() in uuids
+    end
+
+    test "new node ids continue left's offset", %{left: left, result: r} do
+      offset = length(left.graph.nodes)
+
+      new_uuids =
+        MapSet.new([
+          OurShells.ethics_uuid(),
+          OurShells.stewardship_uuid(),
+          OurShells.intent_uuid()
+        ])
+
+      new_nodes = Enum.filter(r.graph.nodes, &MapSet.member?(new_uuids, &1.uuid))
+      ids = new_nodes |> Enum.map(& &1.id) |> Enum.sort()
+      expected = for i <- offset..(offset + 2), do: "n#{i}"
+      assert ids == Enum.sort(expected)
+    end
+
+    test "bind-only nodes preserve their existing id", %{left: left, result: r} do
+      left_by_uuid = Map.new(left.graph.nodes, &{&1.uuid, &1})
+      result_by_uuid = Map.new(r.graph.nodes, &{&1.uuid, &1})
+
+      for uuid <- [
+            OurShells.me_uuid(),
+            OurShells.council_uuid(),
+            OurShells.core_uuid(),
+            OurShells.association_uuid()
+          ] do
+        assert result_by_uuid[uuid].id == left_by_uuid[uuid].id
+      end
+    end
+
+    test "new relationships from args are added", %{left: left, result: r} do
+      assert length(r.graph.relationships) == length(left.graph.relationships) + 4
+    end
+
+    test "the four new KNOWING relationships are present", %{result: r} do
+      result_by_uuid = Map.new(r.graph.nodes, &{&1.uuid, &1})
+
+      pair = fn from_uuid, to_uuid ->
+        from_id = result_by_uuid[from_uuid].id
+        to_id = result_by_uuid[to_uuid].id
+
+        Enum.any?(r.graph.relationships, fn rel ->
+          rel.from_id == from_id and rel.to_id == to_id and rel.type == "KNOWING"
+        end)
+      end
+
+      assert pair.(OurShells.me_uuid(), OurShells.stewardship_uuid())
+      assert pair.(OurShells.council_uuid(), OurShells.ethics_uuid())
+      assert pair.(OurShells.core_uuid(), OurShells.intent_uuid())
+      assert pair.(OurShells.association_uuid(), OurShells.stewardship_uuid())
+    end
+
+    test "new relationships connect the right node ids", %{result: r} do
+      result_by_uuid = Map.new(r.graph.nodes, &{&1.uuid, &1})
+      me_id = result_by_uuid[OurShells.me_uuid()].id
+      stewardship_id = result_by_uuid[OurShells.stewardship_uuid()].id
+
+      assert Enum.any?(r.graph.relationships, fn rel ->
+               rel.from_id == me_id and rel.to_id == stewardship_id and rel.type == "KNOWING"
+             end)
+    end
+
+    test "records :grafted provenance with right title and description", %{left: left, result: r} do
+      assert %{
+               provenance: %{
+                 source: :grafted,
+                 module: ArtefactTest,
+                 left: %{
+                   title: left_title,
+                   base_label: left_bl,
+                   uuid: left_uuid,
+                   provenance: left_prov
+                 },
+                 right: %{title: right_title, description: right_desc}
+               }
+             } = r.metadata
+
+      assert left_title == left.title
+      assert left_bl == left.base_label
+      assert left_uuid == left.uuid
+      assert left_prov == left.metadata.provenance
+
+      assert right_title == "Our Shells and Manifesto"
+      assert right_desc == "Our Shells and Manifesto shape our Association Knowing."
+    end
+  end
+
+  describe "Artefact.graft/3 — opts behaviour" do
+    alias Artefact.Test.Fixtures.OurShells
+
+    test "title and description fall back to left when opts omits them" do
+      left = OurShells.our_shells()
+      result = Artefact.graft(left, OurShells.manifesto_args())
+
+      assert result.title == left.title
+      assert result.description == left.description
+    end
+
+    test "right provenance carries nil when opts omits title and description" do
+      left = OurShells.our_shells()
+      result = Artefact.graft(left, OurShells.manifesto_args())
+
+      assert %{provenance: %{right: %{title: nil, description: nil}}} = result.metadata
+    end
+
+    test "base_label in opts is ignored — left's base_label always wins" do
+      left = OurShells.our_shells()
+      result = Artefact.graft(left, OurShells.manifesto_args(), base_label: "ShouldBeIgnored")
+
+      assert result.base_label == left.base_label
+    end
+  end
+
+  describe "Artefact.graft/3 — bind-only merge semantics" do
+    @left_uuid "019d0000-0000-7000-8000-0000000000aa"
+
+    defp single_node_artefact(labels, properties) do
+      Artefact.new(
+        title: "Left",
+        nodes: [n: [labels: labels, properties: properties, uuid: @left_uuid]],
+        relationships: []
+      )
+    end
+
+    test "bind-only with new labels — labels are unioned" do
+      left = single_node_artefact(["LeftLabel"], %{})
+
+      result =
+        Artefact.graft(left,
+          nodes: [n: [labels: ["RightLabel"], uuid: @left_uuid]],
+          relationships: []
+        )
+
+      [node] = result.graph.nodes
+      assert Enum.sort(node.labels) == ["LeftLabel", "RightLabel"]
+    end
+
+    test "bind-only with shared label — appears once" do
+      left = single_node_artefact(["Shared", "OnlyLeft"], %{})
+
+      result =
+        Artefact.graft(left,
+          nodes: [n: [labels: ["Shared", "OnlyRight"], uuid: @left_uuid]],
+          relationships: []
+        )
+
+      [node] = result.graph.nodes
+      assert Enum.sort(node.labels) == ["OnlyLeft", "OnlyRight", "Shared"]
+    end
+
+    test "bind-only with new property keys — both survive" do
+      left = single_node_artefact([], %{"left_key" => "L"})
+
+      result =
+        Artefact.graft(left,
+          nodes: [n: [properties: %{"right_key" => "R"}, uuid: @left_uuid]],
+          relationships: []
+        )
+
+      [node] = result.graph.nodes
+      assert node.properties == %{"left_key" => "L", "right_key" => "R"}
+    end
+
+    test "bind-only with conflicting property — left wins" do
+      left = single_node_artefact([], %{"shared_key" => "from_left"})
+
+      result =
+        Artefact.graft(left,
+          nodes: [n: [properties: %{"shared_key" => "from_right"}, uuid: @left_uuid]],
+          relationships: []
+        )
+
+      [node] = result.graph.nodes
+      assert node.properties["shared_key"] == "from_left"
+    end
+
+    test "bind-only does not append a new node" do
+      left = single_node_artefact(["X"], %{})
+
+      result =
+        Artefact.graft(left,
+          nodes: [n: [uuid: @left_uuid]],
+          relationships: []
+        )
+
+      assert length(result.graph.nodes) == length(left.graph.nodes)
+    end
+  end
+
+  describe "Artefact.graft/3 — relationship dedupe" do
+    @uuid_a "019d0000-0000-7000-8000-0000000000b1"
+    @uuid_b "019d0000-0000-7000-8000-0000000000b2"
+
+    test "args relationship matching an existing left relationship is deduped (left properties win)" do
+      left =
+        Artefact.new(
+          title: "Pair",
+          nodes: [
+            a: [labels: [], properties: %{}, uuid: @uuid_a],
+            b: [labels: [], properties: %{}, uuid: @uuid_b]
+          ],
+          relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2020"}]]
+        )
+
+      result =
+        Artefact.graft(left,
+          nodes: [
+            a: [uuid: @uuid_a],
+            b: [uuid: @uuid_b]
+          ],
+          relationships: [
+            [
+              from: :a,
+              type: "KNOWS",
+              to: :b,
+              properties: %{"since" => "2099", "source" => "graft"}
+            ]
+          ]
+        )
+
+      assert length(result.graph.relationships) == 1
+      [rel] = result.graph.relationships
+      assert rel.properties["since"] == "2020"
+      assert rel.properties["source"] == "graft"
+    end
+  end
+
+  describe "Artefact.graft/3 — guards" do
+    alias Artefact.Test.Fixtures.OurShells
+
+    test "raises when an args node is missing :uuid" do
+      left = OurShells.our_shells()
+
+      assert_raise ArgumentError, ~r/graft: node :without_uuid is missing required :uuid/, fn ->
+        Artefact.graft(left,
+          nodes: [without_uuid: [labels: ["Knowing"]]],
+          relationships: []
+        )
+      end
+    end
+
+    test "raises when args has duplicate node keys" do
+      left = OurShells.our_shells()
+
+      assert_raise ArgumentError, ~r/graft: duplicate node keys/, fn ->
+        Artefact.graft(left,
+          nodes: [
+            {:dup, [uuid: "019d0000-0000-7000-8000-000000000c01"]},
+            {:dup, [uuid: "019d0000-0000-7000-8000-000000000c02"]}
+          ],
+          relationships: []
+        )
+      end
+    end
+
+    test "raises when a relationship references a key not in args.nodes" do
+      left = OurShells.our_shells()
+
+      assert_raise ArgumentError,
+                   ~r/graft: relationship references unknown node key :ghost/,
+                   fn ->
+                     Artefact.graft(left,
+                       nodes: [{:me, [uuid: OurShells.me_uuid()]}],
+                       relationships: [[from: :me, type: "KNOWING", to: :ghost]]
+                     )
+                   end
+    end
+  end
 end
diff --git a/artefact/test/support/our_shells_fixture.ex b/artefact/test/support/our_shells_fixture.ex
new file mode 100644
index 0000000..1c171d1
--- /dev/null
+++ b/artefact/test/support/our_shells_fixture.ex
@@ -0,0 +1,118 @@
+# SPDX-FileCopyrightText: 2026 artefactory contributors 
+# SPDX-License-Identifier: MIT
+
+defmodule Artefact.Test.Fixtures.OurShells do
+  @moduledoc """
+  Test fixture in `Artefact.new` form, adapted from
+  `diffo-dev/.github/livebook/shells.livemd`.
+
+  Provides a small "Our Shells" artefact (`our_shells/0`) and a matching
+  graft args set (`manifesto_args/0`) that mixes bind-only references to
+  existing nodes with brand-new nodes and relationships.
+
+  Used to exercise `Artefact.graft/3`.
+  """
+
+  require Artefact
+
+  # Stable uuids — same as the shells livebook so the fixture reads the same
+  @me_uuid "019ddb71-c70b-7b3e-83b1-58f4d0be2852"
+  @valuing_uuid "019ddb7f-a43d-7525-bb4f-bfd32d110719"
+  @beings_uuid "019de8bb-86b0-7acf-b1b8-40e96a3775a6"
+  @shells_uuid "019df584-d80b-798a-8b83-077273c43cea"
+  @council_uuid "019df523-66a7-7dca-93c6-ec9579e9408f"
+  @core_uuid "019df524-0bbf-7272-879a-20cba847223b"
+  @association_uuid "019df524-638e-7fba-832a-b0f216843232"
+
+  # Brand-new uuids the graft introduces
+  @ethics_uuid "019df311-16f0-7eea-a66f-a5c502551c6d"
+  @stewardship_uuid "019df318-698c-77d6-bc7b-ea041a019a7f"
+  @intent_uuid "019df317-1c9d-7d84-afe8-0f356db70103"
+
+  def me_uuid, do: @me_uuid
+  def valuing_uuid, do: @valuing_uuid
+  def beings_uuid, do: @beings_uuid
+  def shells_uuid, do: @shells_uuid
+  def council_uuid, do: @council_uuid
+  def core_uuid, do: @core_uuid
+  def association_uuid, do: @association_uuid
+
+  def ethics_uuid, do: @ethics_uuid
+  def stewardship_uuid, do: @stewardship_uuid
+  def intent_uuid, do: @intent_uuid
+
+  @doc """
+  The "Our Shells" artefact — the canonical *left* in graft tests.
+  """
+  def our_shells do
+    me = {:me, [labels: ["Agent"], properties: %{"name" => "me"}, uuid: @me_uuid]}
+
+    valuing =
+      {:valuing, [labels: ["Knowing"], properties: %{"name" => "valuing"}, uuid: @valuing_uuid]}
+
+    beings =
+      {:beings, [labels: ["Valuing"], properties: %{"name" => "beings"}, uuid: @beings_uuid]}
+
+    shells =
+      {:shells, [labels: ["Knowing"], properties: %{"name" => "shells"}, uuid: @shells_uuid]}
+
+    council =
+      {:council,
+       [labels: ["Shell", "Beings"], properties: %{"name" => "council"}, uuid: @council_uuid]}
+
+    core =
+      {:core, [labels: ["Shell", "Beings"], properties: %{"name" => "core"}, uuid: @core_uuid]}
+
+    association =
+      {:association,
+       [
+         labels: ["Shell", "Beings"],
+         properties: %{"name" => "association"},
+         uuid: @association_uuid
+       ]}
+
+    Artefact.new(
+      title: "Our Shells",
+      description: "Our Shells help us value Beings.",
+      nodes: [me, valuing, beings, shells, council, core, association],
+      relationships: [
+        [from: :me, type: "VALUING", to: :valuing],
+        [from: :valuing, type: "CONSIDERING", to: :beings],
+        [from: :me, type: "KNOWING", to: :shells],
+        [from: :beings, type: "LIKELY_IN", to: :shells],
+        [from: :council, type: "INNERMOST", to: :shells],
+        [from: :core, type: "INSIDE", to: :council],
+        [from: :core, type: "INSIDE", to: :association]
+      ]
+    )
+  end
+
+  @doc """
+  Graft args adapted from the shells.livemd "Our Shells and Manifesto"
+  step. Mixes bind-only references (`:me`, `:council`, `:core`,
+  `:association`) with new nodes (`:ethics`, `:stewardship`, `:intent`)
+  and a handful of new relationships that span both.
+  """
+  def manifesto_args do
+    [
+      nodes: [
+        # bind-only — uuid lives in our_shells
+        {:me, [uuid: @me_uuid]},
+        {:council, [uuid: @council_uuid]},
+        {:core, [uuid: @core_uuid]},
+        {:association, [uuid: @association_uuid]},
+        # new
+        {:ethics, [labels: ["Knowing"], properties: %{"name" => "ethics"}, uuid: @ethics_uuid]},
+        {:stewardship,
+         [labels: ["Knowing"], properties: %{"name" => "stewardship"}, uuid: @stewardship_uuid]},
+        {:intent, [labels: ["Knowing"], properties: %{"name" => "intent"}, uuid: @intent_uuid]}
+      ],
+      relationships: [
+        [from: :me, type: "KNOWING", to: :stewardship],
+        [from: :council, type: "KNOWING", to: :ethics],
+        [from: :core, type: "KNOWING", to: :intent],
+        [from: :association, type: "KNOWING", to: :stewardship]
+      ]
+    ]
+  end
+end