One note per contact
Serving a member’s own NOTE beside its group’s lost the member’s on
every macOS edit, which keeps only the last of two; reordering was no
fix, because a lent line missing from a submission propagates its
deletion to every member. A group’s note now rides inside the member’s
single NOTE as a labeled section, split back out on PUT
(docs/plans/2026-09-25-one-note-per-contact.md).

Assisted-by: GLM-5.3 via pi
change nmtyxmplytruxsxxmryryrlwvluqzoln
commit 4f1a1c615a3b318cebe128387890b7eeea57cd8a
author Alpha Chen <alpha@kejadlen.dev>
date
parent zrskwksx
diff --git a/db/migrations/012_one_note_per_contact.rb b/db/migrations/012_one_note_per_contact.rb
new file mode 100644
index 0000000..7489a4c
--- /dev/null
+++ b/db/migrations/012_one_note_per_contact.rb
@@ -0,0 +1,52 @@
+# The one-note invariant lands: a contact carries at most one NOTE,
+# stored and served, and what a group lends composes into it as a
+# section rather than a second line — because macOS keeps only the
+# last of two NOTE lines and a PUT stores the truncation
+# (docs/plans/2026-09-25-one-note-per-contact.md, which this migration
+# makes true of the data the day it lands).
+#
+# What is already stored with several joins into one: the texts with a
+# blank line between, which is the shape the write path joins foreign
+# input into and the shape the sections read beside. No change-log
+# entry is written, 002's own bargain: the stamps are left to say the
+# same, and a client that does not refetch keeps its card until the
+# next change — a PUT of which #subtract_notes reads as the
+# truncation it is rather than a mangle.
+#
+# Spelled out against VCard directly rather than through a Store
+# helper, so what this migration does cannot drift with the app's
+# internals; its test pins the behavior.
+require "pro_tacts/vcard"
+
+Sequel.migration do
+  change do
+    self[:cards].order(:id).each do |row|
+      card = ProTacts::VCard.new(row.fetch(:vcard))
+      notes = card.lines.select { it.names?("NOTE") }
+      next unless notes.length > 1
+
+      texts = notes.map { it.property&.text || it.verbatim.chomp }
+      joined = card.replace("NOTE", ["NOTE:#{ProTacts::VCard.escape(texts.join("\n\n"))}\r\n"])
+      self[:cards].where(id: row.fetch(:id)).update(vcard: joined.to_s)
+    end
+
+    self[:group_properties].select(:group_id).distinct.each do |row|
+      gid = row.fetch(:group_id)
+      rows = self[:group_properties].where(group_id: gid).order(:position).all
+      notes = rows.select { it.fetch(:line)[/\A[^;:]*/].to_s.casecmp?("NOTE") == true }
+      next unless notes.length > 1
+
+      # The joined line keeps the earliest position — the position the
+      # composition lent in, and the one a propagated edit writes back
+      # to — and the rest of the rows go.
+      texts = notes.map {
+        ProTacts::VCard.new(it.fetch(:line)).lines.fetch(0).property&.text || it.fetch(:line)
+      }
+      keep = notes.fetch(0).fetch(:position)
+      self[:group_properties]
+        .where(group_id: gid, position: notes.drop(1).map { it.fetch(:position) }).delete
+      self[:group_properties].where(group_id: gid, position: keep)
+        .update(line: "NOTE:#{ProTacts::VCard.escape(texts.join("\n\n"))}")
+    end
+  end
+end
diff --git a/docs/plans/2026-09-25-one-note-per-contact.md b/docs/plans/2026-09-25-one-note-per-contact.md
new file mode 100644
index 0000000..993350d
--- /dev/null
+++ b/docs/plans/2026-09-25-one-note-per-contact.md
@@ -0,0 +1,118 @@
+# One note per contact
+
+A contact carries one `NOTE`, stored and served, and what a group
+lends rides inside it as a labeled section. The invariant replaces the
+two-line shape composition used to serve, which macOS silently truncated
+(`docs/apple-contacts.md`, "Only the last of two notes survives"): the
+client keeps the last of two `NOTE` lines and drops the rest on its
+next write, and composition appends a group's after the member's own —
+so the member's own note was not just hidden but lost, because a PUT
+stores the submission minus what its groups lend.
+
+Reordering the composition cannot fix that. A lent line absent from a
+submission reads as a deliberate deletion and propagates to every
+member (`EditedContact`, the decided semantics of
+`docs/plans/2026-09-09-group-edits-propagate.md`), so serving the
+group's note first would have one member's macOS edit delete the
+shared note for the whole group. Joining is the only shape where both
+survive and the group's part is still present to attribute.
+
+## The served shape
+
+```
+NOTE:gate code 1854
+call before visiting
+
+Shared · Neighbours:
+bins go out on Tuesday
+```
+
+The member's own text first, then one section per lending group, in
+inheritance order: a blank line, the header `Shared · <label>:`, one
+newline, the body. The label is the group's label — names are unique
+(`db/migrations/008_group_names.rb`), and a nameless group's label is
+its id — and the header is composed, never stored: the group's row
+keeps its bare `NOTE:` line, so the group editor and the propagated
+edit both keep working on bare text, and the split strips the header to
+recover the line.
+
+The header must live in the text value because nowhere else survives
+the client: macOS drops the `X-PT-GROUP` parameter on `NOTE`
+(`docs/apple-contacts.md`), and a property parameter cannot carry a
+provenance the client rewrites away. Words rather than a symbol or
+emoji, because a marker that explains itself is edited as content
+rather than deleted as cruft, and a deleted header is a mangle (below).
+It also reads as prose to someone in Contacts, which is where it is
+seen.
+
+The composer writes the shape exactly one way, so an untouched
+round-trip reproduces the composed bytes and the PUT answers a strong
+etag. The split tolerates what a user's editing does to whitespace
+between sections — blank lines added or removed — because headers, not
+separators, are the structure.
+
+`Shared · ` is rare enough at line start that a collision needs the
+member's own text to open a line with the phrase plus a lending group's
+label. Two sections claiming one label (a collision, or two groups
+whose labels coincide) is a mangle; a section for a label nothing
+lends — stale text from an old mangle — is not a header at all, just
+member text.
+
+## The split
+
+A PUT's `NOTE` is read against what the contact's groups lend:
+
+- A section under a lending label, unchanged — struck, no group edit.
+- Changed — a `GroupEdit` carrying the bare line, the section's text
+  rewritten through the text writer. The one loss is parameters: a
+  group `NOTE` carrying `TYPE` or the like loses them through a
+  propagated edit, the editor never having written them.
+- Absent — the deletion `GroupEdit`, the same semantics a deleted lent
+  line has. Clearing the whole field in Contacts therefore deletes the
+  group's note for every member and the member's own with it: parity
+  with before, where clearing the field deleted the lent line.
+- No NOTE at all in the submission — every section is absent: the
+  deletions above, and no stored note.
+- No headers while groups lend, a header mangled, or two sections
+  claiming one label — the mangle: the whole value stays on the
+  member's stored note, no group row is touched, and an arrival report
+  says so. The group keeps lending, so the next serve shows its note
+  as a fresh section beside the text the member absorbed — duplication
+  rather than loss, visible, reported, and rare.
+
+The member's remainder — everything outside the sections — is the
+stored note, or none when it is blank. Where a group lends nothing the
+NOTE lines are the member's own and keep their bytes: nothing joins,
+and a card that never met a group serves exactly what it stored.
+
+## The invariant
+
+At most one `NOTE`, on a stored card and in a group's lines. Foreign
+input that breaks it is joined, not refused — a client's two-`NOTE`
+submission or an imported card with several joins into one value,
+blank line between, and reports as a broken assumption the way the
+BDAY reports do. What raises is our own bug: a stored card or group
+lines built carrying more than one `NOTE` raise at the write, and the
+raise reaches Sentry with the request. A migration joins the cards and
+group rows already stored, so the invariant is true of the data the
+day it lands.
+
+Import holds the same rule: an arriving note fills a gap
+(`Import::Merge`'s ONE), and several arriving join into the one the
+same way.
+
+## What the admin screens read
+
+The composed value is for clients. The admin card keeps showing the
+member's note and each group's as their own provenance-marked rows,
+read off the contact's own card and its inheritance rather than parsed
+out of the joined value — the same separate-standing the editors
+already have (`Contact#own`).
+
+## Probes outstanding
+
+Two round-trips are asserted by tests against this server's own
+composer and not yet observed from a client: a multiline `NOTE`
+through an unrelated macOS edit, and the same on iOS. Each becomes an
+exchange step when recorded, and an `docs/apple-contacts.md` entry
+either way.
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 03efdf9..62f3f64 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -4,6 +4,7 @@ require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/group_dialog"
 require "pro_tacts/admin/group_label"
+require "pro_tacts/vcard"
 
 module ProTacts
   module Admin
@@ -97,8 +98,31 @@ module ProTacts
           row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.line)
         end
         row("birthday", @birthday) if @birthday
-        @contact.notes.each do |note|
-          row("notes", note.value, note.line)
+        note_rows
+      end
+
+      # The notes read as the editors do — the member's own card and
+      # the inheritance — and not out of the one value the client is
+      # served: a group's note keeps its own row and its group beside
+      # it, the same standing every other lent row has
+      # (docs/plans/2026-09-25-one-note-per-contact.md, "What the
+      # admin screens read").
+      #: () -> void
+      def note_rows
+        @contact.own.notes.each do |note|
+          row("notes", note.value)
+        end
+        @contact.inherited.each do |lent|
+          next unless lent.line[/\A[^;:]*/].to_s.casecmp?("NOTE") == true
+
+          text = VCard.new(lent.line).lines.fetch(0).property&.text
+          next if text.nil?
+
+          dt(class: "type-label") { "notes" }
+          dd(class: "type-body-sm") do
+            div(style: "white-space: pre-wrap;") { text }
+            group_tag(lent.group)
+          end
         end
       end
 
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 149a4ac..aa1f157 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -3,6 +3,7 @@ require "digest"
 require "sentry-ruby"
 
 require "pro_tacts/birthday"
+require "pro_tacts/note_sections"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -150,11 +151,16 @@ module ProTacts
     # The card to serve: the stored one with the inherited lines and
     # the birthday composed back in, in that order, each immediately
     # before END:VCARD — stored + inherited + birthday, the card a
-    # client downloads. A birthday with no wire form, or none at all,
-    # and an empty inheritance leave their halves as they lie, and an
-    # empty inheritance is #insert's own no-op — the same card object —
-    # so a contact in no group serves exactly the bytes it did before
-    # groups existed, etag included. Composed on first ask and
+    # client downloads. A lent NOTE is the one exception: it composes
+    # as a labeled section inside the member's single NOTE, not as a
+    # second line, because macOS keeps only the last of two and a PUT
+    # stores the truncation (docs/plans/2026-09-25-one-note-per-
+    # contact.md, "The served shape"). A birthday with no wire form,
+    # or none at all, and an empty inheritance leave their halves as
+    # they lie, and an empty inheritance is #insert's own no-op — the
+    # same card object — so a contact in no group serves exactly the
+    # bytes it did before groups existed, etag included. Composed on
+    # first ask and
     # memoized, and the etag's derivation deferred with it: a caller
     # that reads neither — and the listing paths read neither — pays
     # for no composition of its own.
@@ -162,9 +168,12 @@ module ProTacts
     def vcard
       return @vcard if defined?(@vcard)
 
-      with_inherited = @stored.insert(@inherited.map { it.line })
+      lent_notes, lent_lines = @inherited.partition { |row| note_row?(row.line) }
+      with_inherited = @stored.insert(lent_lines.map { it.line })
+      composed = composed_note(lent_notes)
+      with_note = composed ? with_inherited.replace("NOTE", [composed]) : with_inherited
       line = @birthday && @birthday.to_line
-      @vcard = line ? with_inherited.insert([line]) : with_inherited
+      @vcard = line ? with_note.insert([line]) : with_note
     end
 
     # The same contact with nothing its groups lend it: the editor's
@@ -347,13 +356,15 @@ module ProTacts
       rows("ADR") { |property, line| address_of(property, line:) }
     end
 
-    # The card's NOTEs (RFC 2426 section 3.6.2), in text form. Every
-    # one, not the first: a member with a note of its own and a group
-    # that lends it another carries two, and RFC 6350 section 6.7.2
-    # settles that this is a card and not a broken one — NOTE's
-    # cardinality is `*`, and 2426 restricts it nowhere. Showing one
-    # would hide whichever the composition happened to put second,
-    # which is always the group's (see #vcard).
+    # The card's NOTEs (RFC 2426 section 3.6.2), in text form. On the
+    # stored card this is at most one — the one-note invariant
+    # (docs/plans/2026-09-25-one-note-per-contact.md) — and on the
+    # composed card it is exactly one wherever a group lends a note,
+    # the member's own and every lent one joined into a single value
+    # that #vcard builds. The reader stays a plural because the
+    # composed card is bytes like any other: a card a client sent
+    # before the invariant, or one this server did not compose, says
+    # whatever its lines say.
     #: () -> Array[Note]
     def notes
       rows("NOTE") { |property, line|
@@ -393,6 +404,40 @@ module ProTacts
       }
     end
 
+    # Whether a lent line is a NOTE, read off its bytes the way
+    # EditedContact reads a property name off them: the group schema
+    # admits no `item1.` prefix to strip (db/migrations/004_groups.rb).
+    #: (String line) -> bool
+    def note_row?(line)
+      line[/\A[^;:]*/].to_s.casecmp?("NOTE") == true
+    end
+
+    # The one NOTE the served card carries, as a content line: the
+    # member's own text and the lent notes as sections, written
+    # through NoteSections and escaped the way the editors write a
+    # text value. Nil where nothing is lent — a member whose groups
+    # hold no note serves its own NOTE's bytes as they lie, whatever
+    # they say, the editor's whole safety.
+    #
+    # A member's own two NOTE lines — a card older than the one-note
+    # migration, or a bug — join as the member's text, which serves
+    # the card this shape means to serve and converges on it at the
+    # next write. A lent line that will not read has no text to
+    # become and is skipped, unreachable through every write the
+    # schema and #shareable? gate.
+    #: (Array[Inherited] rows) -> String?
+    def composed_note(rows)
+      return if rows.empty?
+
+      sections = rows.filter_map { |row|
+        text = VCard.new(row.line).lines.fetch(0).property&.text
+        NoteSections::Section.new(label: row.group.label, text:) if text
+      }
+      member = @stored.lines.select { it.names?("NOTE") }
+        .filter_map { it.property&.text }.join("\n\n")
+      "NOTE:#{VCard.escape(NoteSections.join(member, sections))}\r\n"
+    end
+
     # A property group to the `X-ABLabel` text anchoring it — the
     # label a group's lines carry, on a different line than the row it
     # describes. Read off the composed card: a group lends only bare
diff --git a/lib/pro_tacts/edited_contact.rb b/lib/pro_tacts/edited_contact.rb
index 3b06a44..2e96e21 100644
--- a/lib/pro_tacts/edited_contact.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -3,6 +3,7 @@ require "sentry-ruby"
 require "pro_tacts/birthday"
 require "pro_tacts/birthday_line"
 require "pro_tacts/contact"
+require "pro_tacts/note_sections"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -166,10 +167,13 @@ module ProTacts
     # is stored plus inherited, so stored is submitted minus inherited
     # and a client that PUTs back what it downloaded stores what it
     # started with (docs/plans/2026-08-24-vcard-storage-and-groups.md,
-    # "Groups compose into cards"). Which of four shapes a lent line
-    # came back as, and what each asks of its group, is #classify —
-    # argued in that plan's "Classifying what came back" and in
-    # docs/plans/2026-09-09-group-edits-propagate.md.
+    # "Groups compose into cards"). A lent line that is not a NOTE is
+    # read by the line matching below and #classify — argued in that
+    # plan's "Classifying what came back" and in
+    # docs/plans/2026-09-09-group-edits-propagate.md — and a lent NOTE
+    # by #subtract_notes, because it came back inside the member's own
+    # value rather than as a line of its own
+    # (docs/plans/2026-09-25-one-note-per-contact.md).
     #
     # Two passes, because one lent line must not be attributed a line
     # another lent line would have matched exactly. A member of two
@@ -188,42 +192,174 @@ module ProTacts
     #: (VCard vcard) -> [VCard, Array[GroupEdit]]
     def subtract_inherited(vcard)
       lent = @before&.inherited || [] #: Array[Contact::Inherited]
-      return [vcard, []] if lent.empty?
-
-      unaccounted = unaccounted_lines(vcard)
-      taken = [] #: Array[VCard::Parser::Line]
-
-      moved = lent.reject { |row|
-        candidates = unaccounted.select { it.names?(property_name(row.line)) }
-        # Blind to lines saying the same thing the way the editor's
-        # digests are blind to identical bytes
-        # (VCard::Parser::Line#digest): where a member's own card
-        # carries what its group lends, which copy this takes is
-        # undecidable and their saying the same thing makes it not
-        # matter.
-        match = candidates.find { unedited?(it, parsed_line(row.line), unaccounted) }
-        if match
-          label = label_of(match, unaccounted)
-          taken << strike(unaccounted, match)
-          taken << strike(unaccounted, label) if label
+      note_rows, line_rows = lent.partition { |row| property_name(row.line).casecmp?("NOTE") == true }
+
+      edits = [] #: Array[GroupEdit]
+      struck = vcard
+      if line_rows.any?
+        unaccounted = unaccounted_lines(vcard)
+        taken = [] #: Array[VCard::Parser::Line]
+
+        moved = line_rows.reject { |row|
+          candidates = unaccounted.select { it.names?(property_name(row.line)) }
+          # Blind to lines saying the same thing the way the editor's
+          # digests are blind to identical bytes
+          # (VCard::Parser::Line#digest): where a member's own card
+          # carries what its group lends, which copy this takes is
+          # undecidable and their saying the same thing makes it not
+          # matter.
+          match = candidates.find { unedited?(it, parsed_line(row.line), unaccounted) }
+          if match
+            label = label_of(match, unaccounted)
+            taken << strike(unaccounted, match)
+            taken << strike(unaccounted, label) if label
+          end
+          match
+        }
+
+        ambiguous = 0
+        unshareable = 0
+        moved.each do |row|
+          case classify(row, unaccounted, taken)
+          in GroupEdit => edit then edits << edit
+          in :ambiguous then ambiguous += 1
+          in :unshareable then unshareable += 1
+          end
         end
-        match
+
+        report_ambiguous_inherited_lines(ambiguous)
+        report_unshareable_lines(unshareable)
+        struck = taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
+      end
+
+      card, note_edits = subtract_notes(struck, note_rows)
+      [card, edits + note_edits]
+    end
+
+    # The one NOTE the submission carries, read back into what the
+    # member keeps and what each lending group is asked of — the
+    # inverse of what Contact#vcard composes, over sections rather
+    # than lines. Where no group lends a note the NOTE lines are the
+    # member's own and keep their bytes, joined into one and reported
+    # only where a client sent several — the one-note invariant's
+    # answer to foreign input (docs/plans/2026-09-25-one-note-per-
+    # contact.md, "The split" and "The invariant").
+    #
+    # The one reading that is not the section grammar's: a value with
+    # no headers that is exactly a lent note's text is a client of the
+    # two-line shape sending back what macOS truncated it to — the
+    # last NOTE, which composition put last (docs/apple-contacts.md,
+    # "Only the last of two notes survives"). It reads as that note
+    # present and every other one gone, the reading the line matching
+    # made of the same card before sections existed.
+    #: (VCard vcard, Array[Contact::Inherited] rows) -> [VCard, Array[GroupEdit]]
+    def subtract_notes(vcard, rows)
+      notes = vcard.lines.select { it.names?("NOTE") }
+      return [members_own(vcard, notes), []] if rows.empty?
+
+      labels = rows.map { it.group.label }
+      if labels.uniq.length != labels.length
+        report_indistinguishable_labels
+        return [members_own(vcard, notes), []]
+      end
+
+      # No NOTE at all is every section deleted — the cleared field,
+      # which takes the member's own text with it the way taking the
+      # lent line always did.
+      deletions = rows.map { |row|
+        GroupEdit.new(group_id: row.group.id, position: row.position, line: nil)
       }
+      return [vcard, deletions] if notes.empty?
 
-      edits = [] #: Array[GroupEdit]
-      ambiguous = 0
-      unshareable = 0
-      moved.each do |row|
-        case classify(row, unaccounted, taken)
-        in GroupEdit => edit then edits << edit
-        in :ambiguous then ambiguous += 1
-        in :unshareable then unshareable += 1
+      values = notes.map { it.property&.text }
+      if values.any?(&:nil?)
+        report_unreadable_sections
+        return [members_own(vcard, notes), []]
+      end
+      value = values.join("\n\n")
+      split = NoteSections.split(value, labels)
+      if split.sections.empty?
+        # A value with no headers that is exactly a lent note's text is
+        # a client of the two-line shape sending back what macOS
+        # truncated it to — the last NOTE, which composition put last
+        # (docs/apple-contacts.md, "Only the last of two notes
+        # survives"). It reads as that note present and every other
+        # one gone, the reading the line matching made of the same
+        # card before sections existed.
+        kept = rows.find { |row| lent_text(row) == value }
+        unless kept.nil?
+          return [
+            vcard.replace("NOTE", []),
+            rows.reject { it.equal?(kept) }.map { |row|
+              GroupEdit.new(group_id: row.group.id, position: row.position, line: nil)
+            },
+          ]
+        end
+
+        report_unreadable_sections
+        return [members_own(vcard, notes), []]
+      end
+
+      if split.sections.length != split.sections.map(&:label).uniq.length
+        report_unreadable_sections
+        return [members_own(vcard, notes), []]
+      end
+
+      texts = split.sections.to_h {
+        [it.label, it.text] #: [String, String]
+      }
+      edits = rows.filter_map { |row|
+        text = texts[row.group.label]
+        case text
+        when nil
+          GroupEdit.new(group_id: row.group.id, position: row.position, line: nil)
+        when lent_text(row)
+          nil
+        else
+          GroupEdit.new(
+            group_id: row.group.id,
+            position: row.position,
+            line: "NOTE:#{VCard.escape(text)}",
+          )
         end
+      }
+      if split.member.empty?
+        member = [] #: Array[String]
+      else
+        member = ["NOTE:#{VCard.escape(split.member)}\r\n"]
       end
+      [vcard.replace("NOTE", member), edits]
+    end
+
+    # The submission's NOTE lines kept as the member's own: their
+    # bytes where there is one, joined where foreign input sent
+    # several — the one-note invariant's answer to a client
+    # (docs/plans/2026-09-25-one-note-per-contact.md, "The invariant").
+    #: (VCard vcard, Array[VCard::Parser::Line] notes) -> VCard
+    def members_own(vcard, notes)
+      return vcard if notes.length <= 1
+
+      report_many_note_lines(notes.length)
+      joined_notes(vcard, notes)
+    end
+
+    # What a lent NOTE row says, as text: the unescaped value of its
+    # line, or nil for one that will not read and so cannot be compared
+    # with anything a client sent.
+    #: (Contact::Inherited row) -> String?
+    def lent_text(row)
+      parsed_line(row.line).property&.text
+    end
 
-      report_ambiguous_inherited_lines(ambiguous)
-      report_unshareable_lines(unshareable)
-      [taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }, edits]
+    # The submission's NOTE lines joined into the one the invariant
+    # keeps, at the first one's position: a client's two-NOTE card is
+    # foreign input, joined rather than refused. A line that will not
+    # read has no text to join, so its bytes join as they lie — the
+    # one shape that loses nothing a person could have written.
+    #: (VCard vcard, Array[VCard::Parser::Line] notes) -> VCard
+    def joined_notes(vcard, notes)
+      texts = notes.map { it.property&.text || it.verbatim.chomp }
+      vcard.replace("NOTE", ["NOTE:#{VCard.escape(texts.join("\n\n"))}\r\n"])
     end
 
     # What a lent line that came back changed asks of its group: the
@@ -421,5 +557,43 @@ module ProTacts
         level: :warning,
       )
     end
+
+    # A contact has one note (docs/plans/2026-09-25-one-note-per-
+    # contact.md), so a submission with several is a client this server
+    # did not compose for; they join, and the news is worth having
+    # because whatever each carried is now one value on one member.
+    #: (Integer count) -> void
+    def report_many_note_lines(count)
+      Sentry.capture_message("a submitted card carried #{count} NOTE lines", level: :warning)
+    end
+
+    # The mangle report: a NOTE this server cannot read back into
+    # sections — a header a person's editing took apart, two sections
+    # claiming one label, or a line that will not read. The whole
+    # value stays on the member and the groups keep what they lend, so
+    # the member serves its own copy and each group's section beside
+    # it until someone reconciles them, which is why the news is worth
+    # having (docs/plans/2026-09-25-one-note-per-contact.md, "The
+    # split").
+    #: () -> void
+    def report_unreadable_sections
+      Sentry.capture_message(
+        "a submitted NOTE could not be read back into its sections; it stays on the member",
+        level: :warning,
+      )
+    end
+
+    # Two of a contact's groups lend under one label — a nameless
+    # group's id and a group named the same — so a section read under
+    # that label cannot say which lent it. Nothing is attributed and
+    # nothing propagates, the same bargain #report_unreadable_sections
+    # makes.
+    #: () -> void
+    def report_indistinguishable_labels
+      Sentry.capture_message(
+        "two of a contact's groups share a label, so its submitted NOTE cannot be attributed",
+        level: :warning,
+      )
+    end
   end
 end
diff --git a/lib/pro_tacts/import/merge.rb b/lib/pro_tacts/import/merge.rb
index 2e92f18..76dc0b1 100644
--- a/lib/pro_tacts/import/merge.rb
+++ b/lib/pro_tacts/import/merge.rb
@@ -30,6 +30,7 @@ module ProTacts
       # @rbs @labels: Hash[String, Array[VCard::Parser::Line]]
       # @rbs @groups: Hash[String, String]
       # @rbs @named_groups: Array[String]
+      # @rbs @arriving_notes: Array[String]
       # @rbs @contact: Contact
       # @rbs @left: Array[VCard::Parser::Line]
 
@@ -55,6 +56,7 @@ module ProTacts
         @groups = {} #: Hash[String, String]
         @named_groups = into.stored.properties.filter_map { it.group&.downcase }
         taken = [] #: Array[String]
+        arriving_notes = [] #: Array[String]
         left = [] #: Array[VCard::Parser::Line]
         birthday = into.birthday
         card.lines.each do |line|
@@ -72,7 +74,14 @@ module ProTacts
           elsif ONE.include?(name)
             own = into.stored.property(name)
             if own.nil?
-              taken.concat(moved(line, property))
+              # A contact holds one note, so several arriving join into
+              # the one they fill (docs/plans/2026-09-25-one-note-per-
+              # contact.md); the joined line is written below the walk.
+              if name == "NOTE"
+                arriving_notes << property.text
+              else
+                taken.concat(moved(line, property))
+              end
             elsif own.value != property.value
               left << line
             end
@@ -95,6 +104,12 @@ module ProTacts
           end
         end
         @left = left
+        # The arriving notes' one line, at the walk's end: whatever
+        # order they arrived in, the join is the value the book keeps
+        # and the one-note invariant asks for.
+        unless arriving_notes.empty?
+          taken << "NOTE:#{VCard.escape(arriving_notes.join("\n\n"))}\r\n"
+        end
         @contact = into.with(stored: into.stored.insert(taken), birthday:)
       end
 
diff --git a/lib/pro_tacts/note_sections.rb b/lib/pro_tacts/note_sections.rb
new file mode 100644
index 0000000..574cdcc
--- /dev/null
+++ b/lib/pro_tacts/note_sections.rb
@@ -0,0 +1,102 @@
+module ProTacts
+  # The shape of the one NOTE a served card carries: the member's own
+  # text first, then a section per lending group — a blank line, the
+  # header `Shared · <label>:`, one newline, the body. Why a group's
+  # note rides inside the text value and nowhere else, and why the
+  # header is words, is docs/plans/2026-09-25-one-note-per-contact.md.
+  #
+  # Pure text algebra, the two halves of one round trip: #join is what
+  # composition writes, exactly one way, so an untouched card composes
+  # back to the bytes it was served with; #split is what a PUT is read
+  # with, tolerant of the whitespace a person's editing moves between
+  # sections, because headers and not separators are the structure.
+  # Neither knows what a group is — attribution is EditedContact's,
+  # which knows what the contact's groups lend.
+  module NoteSections
+    # The fixed half of every header. Rare enough at line start that a
+    # collision needs a member's own text to open a line with the
+    # phrase plus a lending group's label.
+    HEADER = "Shared · ".freeze #: String
+
+    # One labeled part of a combined value: the lending group's label
+    # and the text its row holds, shorn of its NOTE line. A Data class,
+    # whose members the inline parser cannot read (see
+    # sig/pro_tacts/contact.rbs).
+    # @rbs skip
+    Section = Data.define(:label, :text)
+
+    # What #split read out of a value: the member's own text ("" for
+    # none) and the sections in the order they stand. The same shape
+    # for a value with nothing lent, whose labels were empty and whose
+    # text is all member.
+    # @rbs skip
+    Split = Data.define(:member, :sections)
+
+    # The value to serve: member text and sections in the order given,
+    # blank line between parts. A part with no text is its header
+    # alone. Which sections and in what order is the caller's —
+    # inheritance order, the order the group's rows lend in.
+    #: (String member, Array[Section] sections) -> String
+    def self.join(member, sections)
+      parts = [] #: Array[String]
+      parts << member unless member.empty?
+      sections.each do |section|
+        parts << (section.text.empty? ? header(section.label) : "#{header(section.label)}\n#{section.text}")
+      end
+      parts.join("\n\n")
+    end
+
+    # The value read back: everything before the first header is the
+    # member's own text, each header opens the section of its label,
+    # and a section runs to the next header or the end. A header is a
+    # line exactly equal to `Shared · <label>:` with trailing
+    # whitespace gone, for none of the labels it names — a label
+    # nothing lends is a header nowhere, so stale text from an old
+    # value reads as member text and not as a mangle. Duplicate labels
+    # are the caller's to refuse before this: one header cannot say
+    # which of two it names. Leading and trailing blank lines come
+    # off both halves — they are the separators the composer writes and
+    # whatever a person left behind — and blank lines inside a body
+    # stay, a multiline note keeping its own paragraphs. A member text
+    # that was all blanks reads as "", which is the caller's no-note.
+    #: (String value, Array[String] labels) -> Split
+    def self.split(value, labels)
+      headers = labels.to_h {
+        [header(it), it] #: [String, String]
+      }
+      member = [] #: Array[String]
+      found = [] #: Array[Array[String]]
+      value.split("\n", -1).each do |line|
+        label = headers[line.rstrip]
+        if label.nil?
+          if found.empty?
+            member << line
+          else
+            found.fetch(-1) << line
+          end
+        else
+          found << [label]
+        end
+      end
+      Split.new(
+        member: trimmed(member),
+        sections: found.map { |lines| Section.new(label: lines.fetch(0), text: trimmed(lines.drop(1))) },
+      )
+    end
+
+    #: (String label) -> String
+    def self.header(label)
+      "#{HEADER}#{label}:"
+    end
+
+    # The lines with their leading and trailing blank lines off —
+    # everything a body is, and none of the space around it.
+    #: (Array[String] lines) -> String
+    def self.trimmed(lines)
+      from = lines.index { !it.strip.empty? } || lines.length
+      body = lines[from..] || [] #: Array[String]
+      upto = body.rindex { !it.strip.empty? } || -1
+      body[0..upto].to_a.join("\n")
+    end
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index f6524fb..820db76 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -442,6 +442,7 @@ module ProTacts
       # change log's diff.
       before = contact(id)
       stored, birthday, edits = EditedContact.new(vcard, before:).decomposed
+      assert_one_note(stored)
 
       # The Contact this returns is the composed one — the lent lines
       # put back, read off membership as it stands at this write — and
@@ -498,6 +499,7 @@ module ProTacts
     #: (String id, VCard vcard, birthday: Birthday?) -> Contact
     def save_edit(id, vcard, birthday:)
       before = contact(id)
+      assert_one_note(vcard)
       contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
       @database.transaction do
         upsert_card(contact.id, vcard)
@@ -694,6 +696,9 @@ module ProTacts
     # and NOTE lines — and raises.
     #: (String id, Array[String] lines) -> void
     def set_group_lines(id, lines)
+      notes = lines.count { it[/\A[^;:]*/].to_s.casecmp?("NOTE") == true }
+      raise "a group was given #{notes} NOTE lines" if notes > 1
+
       @database.transaction do
         was = group_properties.where(group_id: id).order(:position).select_map(:line)
         diff = CardDiff.between_lines(was, lines)
@@ -926,6 +931,18 @@ module ProTacts
       @database[:group_properties]
     end
 
+    # The one-note invariant's raise: a card this server built with
+    # more than one NOTE is a bug, and a raise reaches Sentry with the
+    # request (docs/plans/2026-09-25-one-note-per-contact.md, "The
+    # invariant"). Foreign input never gets here — EditedContact and
+    # Import::Merge join what arrives — so the message carries no card
+    # content because there is none it could usefully carry.
+    #: (VCard vcard) -> void
+    def assert_one_note(vcard)
+      count = vcard.lines.count { it.names?("NOTE") }
+      raise "a stored card was built with #{count} NOTE lines" if count > 1
+    end
+
     #: () -> Sequel::Dataset
     def group_changes
       @database[:group_changes]
diff --git a/sig/pro_tacts/note_sections.rbs b/sig/pro_tacts/note_sections.rbs
new file mode 100644
index 0000000..3b035dd
--- /dev/null
+++ b/sig/pro_tacts/note_sections.rbs
@@ -0,0 +1,20 @@
+# NoteSections::Section and ::Split are Data classes, whose members
+# the inline parser cannot read (see sig/pro_tacts/contact.rbs). The
+# module's own methods carry their types inline in note_sections.rb.
+module ProTacts
+  module NoteSections
+    class Section
+      attr_reader label: String
+      attr_reader text: String
+
+      def self.new: (label: String, text: String) -> instance
+    end
+
+    class Split
+      attr_reader member: String
+      attr_reader sections: Array[Section]
+
+      def self.new: (member: String, sections: Array[Section]) -> instance
+    end
+  end
+end
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index ce28b48..2d568fc 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -51,11 +51,15 @@ module FixtureData
 
   # The groups the fixture book carries. The first is a household, the
   # three household-* cards in it. Those cards are a family — one
-  # surname, a mobile and an email each, and no address or note of
-  # their own — so everything the admin UI shows on them in those two
-  # rows is the group's, and removing a member visibly takes it away.
-  # They sit outside the recorded macOS exchange's hrefs, so the replay
-  # keeps serving that session the bytes it saw.
+  # surname, a mobile and an email each, and no address of their own —
+  # so the address row the admin UI shows on them is the group's, and
+  # removing a member visibly takes it away. Alicia carries a note of
+  # her own beside the group's, the one shape the one-note composition
+  # exists for (docs/plans/2026-09-25-one-note-per-contact.md): her
+  # served card joins both into a single NOTE, and her page shows them
+  # as two provenance-marked rows. They sit outside the recorded macOS
+  # exchange's hrefs, so the replay keeps serving that session the
+  # bytes it saw.
   #
   # The second is the nameless case: every bday-* card in one group
   # with no name and nothing to lend
diff --git a/test/fixtures/cards/household-alicia.vcf b/test/fixtures/cards/household-alicia.vcf
index 6b15070..05bb660 100644
--- a/test/fixtures/cards/household-alicia.vcf
+++ b/test/fixtures/cards/household-alicia.vcf
@@ -4,6 +4,7 @@ N:Boole;Alicia;;;
 FN:Alicia Boole
 EMAIL:alicia@example.com
 TEL;TYPE=cell:+44 7700 900735
+NOTE:Prefers the back door\, the bell wakes the dog.\n\nLactose-free since March.
 BDAY:1860-06-08
 UID:household-alicia
 END:VCARD
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 161899b..c4c12ff 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -159,7 +159,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-alicia.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"400976f14e4190967319e89b0ba65c8b164f3f821d376fc72b26a91c1c1c30b3"</d:getetag>
+        <d:getetag>"7fd819264a8b5948cf6c3481f699487325dcc06ef87bfb7c05b304072bcbf6a9"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -168,7 +168,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-george.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"08cb40c43b096ddd99a96f65b9f14ebc0ee0d0bfec50db3c5835f19c3c2d3c8c"</d:getetag>
+        <d:getetag>"202b79645f5a7e59f8c4b1f7e6a8c4c7ca5713a31ad063a544d590d31f9b3469"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -177,7 +177,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-mary.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"e3e6ee61a5fab7bc63b6fb6892b1e3543390d0436ca855a29d9dca8ea7a8393c"</d:getetag>
+        <d:getetag>"560b20be0da57be6ed51c2f3a2f04cca803853fa341458877f7de14111ebf018"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index efe46ff..3fed26d 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -124,7 +124,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-alicia.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"400976f14e4190967319e89b0ba65c8b164f3f821d376fc72b26a91c1c1c30b3"</d:getetag>
+        <d:getetag>"7fd819264a8b5948cf6c3481f699487325dcc06ef87bfb7c05b304072bcbf6a9"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -133,7 +133,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-george.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"08cb40c43b096ddd99a96f65b9f14ebc0ee0d0bfec50db3c5835f19c3c2d3c8c"</d:getetag>
+        <d:getetag>"202b79645f5a7e59f8c4b1f7e6a8c4c7ca5713a31ad063a544d590d31f9b3469"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -142,7 +142,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-mary.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"e3e6ee61a5fab7bc63b6fb6892b1e3543390d0436ca855a29d9dca8ea7a8393c"</d:getetag>
+        <d:getetag>"560b20be0da57be6ed51c2f3a2f04cca803853fa341458877f7de14111ebf018"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 161899b..c4c12ff 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -159,7 +159,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-alicia.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"400976f14e4190967319e89b0ba65c8b164f3f821d376fc72b26a91c1c1c30b3"</d:getetag>
+        <d:getetag>"7fd819264a8b5948cf6c3481f699487325dcc06ef87bfb7c05b304072bcbf6a9"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -168,7 +168,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-george.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"08cb40c43b096ddd99a96f65b9f14ebc0ee0d0bfec50db3c5835f19c3c2d3c8c"</d:getetag>
+        <d:getetag>"202b79645f5a7e59f8c4b1f7e6a8c4c7ca5713a31ad063a544d590d31f9b3469"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
@@ -177,7 +177,7 @@ Content-Type: text/xml
     <d:href>/dav/addressbook/household-mary.vcf</d:href>
     <d:propstat>
       <d:prop>
-        <d:getetag>"e3e6ee61a5fab7bc63b6fb6892b1e3543390d0436ca855a29d9dca8ea7a8393c"</d:getetag>
+        <d:getetag>"560b20be0da57be6ed51c2f3a2f04cca803853fa341458877f7de14111ebf018"</d:getetag>
       </d:prop>
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
diff --git a/test/pro_tacts/import/test_merge.rb b/test/pro_tacts/import/test_merge.rb
index e72a7cf..f1dc169 100644
--- a/test/pro_tacts/import/test_merge.rb
+++ b/test/pro_tacts/import/test_merge.rb
@@ -81,6 +81,27 @@ class ImportMergeTest < Minitest::Test
     assert_equal "JB", folded.nickname
   end
 
+  # A contact holds one note, so several arriving fill the gap as one
+  # value — a blank line between, the shape the write path joins
+  # foreign input into and the sections read beside
+  # (docs/plans/2026-09-25-one-note-per-contact.md).
+  def test_several_arriving_notes_join_into_the_one_gap
+    bare = <<~CARD.gsub("\n", "\r\n")
+      BEGIN:VCARD
+      VERSION:3.0
+      N:Booles;Janet;;;
+      FN:Janet Booles
+      NOTE:From the school list
+      NOTE:Chair of the residents' association
+      UID:ABC-123
+      END:VCARD
+    CARD
+    folded = merge(jane(STORED.sub("NOTE:Met at work\r\n", "")), bare).contact
+
+    assert_equal ["From the school list\n\nChair of the residents' association"], folded.notes.map(&:value)
+    assert_includes folded.stored.to_s, "NOTE:From the school list\\n\\nChair of the residents' association\r\n"
+  end
+
   # A birthday the contact has none of goes into the model, the split
   # a stored contact is held in.
   def test_a_birthday_the_contact_lacks_comes_into_the_model
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 0462c88..8c177c9 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -394,17 +394,86 @@ class ContactTest < Minitest::Test
     assert_equal GROUP, contact.group_of(addresses.fetch(1).line)
   end
 
-  # A member with a note of its own and a group that lends it another
-  # carries both: NOTE's cardinality is `*` (RFC 6350 section 6.7.2),
-  # so the accessor reads every one — the member's first, the group's
-  # after it, the order #vcard composes.
-  def test_notes_read_the_members_own_and_the_groups
-    contact = contact(STRUCTURED, inherited: ["NOTE:Gate code 1854."])
-    notes = contact.notes
-
-    assert_equal ["Countess, mathematician.", "Gate code 1854."], notes.map { it.value }
+  # A lent NOTE composes into the member's own as one value — the
+  # served card carries one NOTE, not two, because macOS keeps only
+  # the last of two and a PUT would store the truncation
+  # (docs/plans/2026-09-25-one-note-per-contact.md). The member's own
+  # text first, the group's as a section after it, at the member's own
+  # NOTE position.
+  def test_a_lent_note_composes_into_the_members_own
+    composed = contact(STRUCTURED, inherited: ["NOTE:Gate code 1854."]).vcard.to_s
+
+    assert_equal(
+      STRUCTURED.sub(
+        "NOTE:Countess\\, mathematician.\r\n",
+        "NOTE:Countess\\, mathematician.\\n\\nShared · Booles:\\nGate code 1854.\r\n",
+      ),
+      composed,
+    )
+  end
+
+  # A member with no note of its own serves the section alone, the
+  # one NOTE inserted before END:VCARD the way a lent line is.
+  def test_a_member_with_no_note_of_its_own_serves_the_section_alone
+    composed = contact(CARD, inherited: ["NOTE:Gate code 1854."]).vcard.to_s
+
+    assert_equal(
+      CARD.sub("END:VCARD\r\n", "NOTE:Shared · Booles:\\nGate code 1854.\r\nEND:VCARD\r\n"),
+      composed,
+    )
+  end
+
+  # Nothing lent that is a note, and the member's own NOTE serves as
+  # its bytes lie — the editor's whole safety, and the reason #composed_note
+  # answers nil over an empty one. What else the group lends composes
+  # as lines around it.
+  def test_a_contact_whose_groups_lend_no_note_serves_its_own_note_bytes
+    composed = contact(STRUCTURED, inherited: ["TEL;TYPE=home:+44 20 5555 0100"]).vcard.to_s
+
+    assert_equal(
+      STRUCTURED.sub("END:VCARD\r\n", "TEL;TYPE=home:+44 20 5555 0100\r\nEND:VCARD\r\n"),
+      composed,
+    )
+    assert_includes composed, "NOTE:Countess\\, mathematician.\r\n"
+  end
+
+  # The composed NOTE stands at the member's own NOTE position — a
+  # lent line's position is insert's, before END:VCARD, and the
+  # birthday lands after everything else.
+  def test_the_composed_note_lands_at_the_members_own_note_position
+    birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+    composed = STRUCTURED.sub(
+      "NOTE:Countess\\, mathematician.\r\n",
+      "NOTE:Countess\\, mathematician.\\n\\nShared · Booles:\\nGate code 1854.\r\n",
+    ).sub(
+      "END:VCARD\r\n",
+      "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom\r\n" \
+      "BDAY:1985-12-10\r\nEND:VCARD\r\n",
+    )
+
+    assert_equal(
+      composed,
+      contact(
+        STRUCTURED,
+        birthday:,
+        inherited: [
+          "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
+          "NOTE:Gate code 1854.",
+        ],
+      ).vcard.to_s,
+    )
+  end
+
+  # The joined line matches nothing by bytes — neither the member's
+  # own nor what a group lends — so a screen asking after provenance
+  # reads the stored card and the inheritance, not the composed value
+  # (Admin::ContactCard's note rows).
+  def test_the_composed_note_names_no_group
+    notes = contact(STRUCTURED, inherited: ["NOTE:Gate code 1854."]).notes
+
+    assert_equal 1, notes.size
     assert_nil contact.group_of(notes.fetch(0).line)
-    assert_equal GROUP, contact.group_of(notes.fetch(1).line)
+    assert_equal ["Countess, mathematician."], contact(STRUCTURED, inherited: ["NOTE:Gate code 1854."]).own.notes.map { it.value }
   end
 
   def test_unescapes_notes
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
index 040787d..df3061f 100644
--- a/test/pro_tacts/test_edited_contact.rb
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -143,4 +143,173 @@ class EditedContactTest < Minitest::Test
     assert_equal [AIDEN, APRIL_12, []],
                  decompose(with_lines(HOUSEHOLD_ADDRESS, "BDAY:1985-04-12"), before: before(inherited: LENT))
   end
+
+  ## The lent notes
+
+  HOUSEHOLD_NOTE = "NOTE:Gate code 1854. The dog is friendly\\, the goose is not." #: String
+  NOTE_TEXT = "Gate code 1854. The dog is friendly, the goose is not." #: String
+  NEIGHBOURS = ProTacts::Group.new(
+    id: "nnnn", name: "Neighbours", label: "Neighbours", lines: [HOUSEHOLD_NOTE], members: ["aiden"],
+  )
+  LENT_NOTE = [ProTacts::Contact::Inherited.new(group: NEIGHBOURS, position: 1, line: HOUSEHOLD_NOTE)].freeze
+
+  def note_card(text) = AIDEN.sub("END:VCARD\r\n", "NOTE:#{ProTacts::VCard.escape(text)}\r\nEND:VCARD\r\n")
+
+  def neighbour_edit(line) = ProTacts::EditedContact::GroupEdit.new(group_id: "nnnn", position: 1, line:)
+
+  # What Contact#vcard composes, PUT back, stores what was stored and
+  # asks nothing of the group — the round trip the strong etag answer
+  # stands on, and the one a client that touched nothing makes.
+  def test_the_served_note_round_trips
+    stored = note_card("Own note.")
+    served = note_card("Own note.\n\nShared · Neighbours:\n#{NOTE_TEXT}")
+
+    assert_equal [stored, nil, []], decompose(served, before: before(stored, inherited: LENT_NOTE))
+    assert_empty sentry_messages
+  end
+
+  # A person's whitespace between the member's text and a section is
+  # not an edit: the blank line the composer writes is structure the
+  # split tolerates the loss of.
+  def test_whitespace_a_person_moved_is_not_an_edit
+    stored = note_card("Own note.")
+    served = note_card("Own note.\nShared · Neighbours:\n#{NOTE_TEXT}")
+
+    assert_equal [stored, nil, []], decompose(served, before: before(stored, inherited: LENT_NOTE))
+  end
+
+  def test_an_edited_section_is_an_edit_of_its_row
+    stored = note_card("Own note.")
+    served = note_card("Own note.\n\nShared · Neighbours:\nBins go out on Tuesday.")
+
+    assert_equal(
+      [stored, nil, [neighbour_edit("NOTE:Bins go out on Tuesday.")]],
+      decompose(served, before: before(stored, inherited: LENT_NOTE)),
+    )
+  end
+
+  # The row receives a text value's escapes, the unit group_properties
+  # holds — the same writer the editors use.
+  def test_an_edited_section_escapes_what_the_row_receives
+    stored = note_card("Own note.")
+    served = note_card("Own note.\n\nShared · Neighbours:\nDog, friendly; goose, not.")
+
+    assert_equal(
+      [stored, nil, [neighbour_edit("NOTE:Dog\\, friendly\\; goose\\, not.")]],
+      decompose(served, before: before(stored, inherited: LENT_NOTE)),
+    )
+  end
+
+  # One of two sections deleted, the other and the member's own kept:
+  # the present section is attributed and the absent one is the
+  # removal of its row.
+  DOGWALKERS = ProTacts::Group.new(
+    id: "dddd", name: "Dog walkers", label: "Dog walkers", lines: ["NOTE:Sunday mornings."], members: ["aiden"],
+  )
+
+  def test_a_deleted_section_is_a_removal_of_its_row
+    stored = note_card("Own note.")
+    served = note_card("Own note.\n\nShared · Neighbours:\n#{NOTE_TEXT}")
+    lent = [
+      *LENT_NOTE,
+      ProTacts::Contact::Inherited.new(group: DOGWALKERS, position: 2, line: "NOTE:Sunday mornings."),
+    ]
+
+    assert_equal(
+      [stored, nil,
+       [ProTacts::EditedContact::GroupEdit.new(group_id: "dddd", position: 2, line: nil)]],
+      decompose(served, before: before(stored, inherited: lent)),
+    )
+  end
+
+  # A member's text alone, header gone with the section, is the one
+  # shape that cannot say whether the section was deleted or the
+  # header was taken apart — the mangle, which propagates nothing
+  # rather than guess a deletion.
+  def test_a_note_left_as_the_members_own_alone_stays_whole
+    served = note_card("Own note.")
+
+    assert_equal [served, nil, []], decompose(served, before: before(served, inherited: LENT_NOTE))
+    assert_equal 1, sentry_messages.length
+  end
+
+  # The cleared field: no NOTE came back at all, and every lent row
+  # goes with the member's own text.
+  def test_a_cleared_note_removes_every_lent_row
+    assert_equal(
+      [AIDEN, nil, [neighbour_edit(nil)]],
+      decompose(AIDEN, before: before(note_card("Own note."), inherited: LENT_NOTE)),
+    )
+  end
+
+  # A client of the two-line shape sending back what macOS truncated
+  # it to: the last NOTE, which composition put last — the group's. The
+  # value with no header is exactly the lent text, which reads as that
+  # section present and the member's own gone.
+  def test_a_truncated_note_reads_as_the_kept_section
+    assert_equal(
+      [AIDEN, nil, []],
+      decompose(note_card(NOTE_TEXT), before: before(note_card("Own note."), inherited: LENT_NOTE)),
+    )
+    assert_empty sentry_messages
+  end
+
+  # Headerless text that is nothing any group lends: the mangle. The
+  # whole value stays on the member, the group keeps what it lends,
+  # and the arrival report says so.
+  def test_an_unreadable_note_stays_whole_on_the_member_and_is_reported
+    served = note_card("whatever a person typed")
+
+    assert_equal [served, nil, []], decompose(served, before: before(note_card("Own note."), inherited: LENT_NOTE))
+    assert_equal ["a submitted NOTE could not be read back into its sections; it stays on the member"],
+                 sentry_messages
+  end
+
+  # The collision: the member's own text opens a line with the header,
+  # and two sections claim one label.
+  def test_two_sections_of_one_label_stay_whole_and_are_reported
+    served = note_card("Shared · Neighbours:\nfirst\n\nShared · Neighbours:\n#{NOTE_TEXT}")
+
+    assert_equal [served, nil, []], decompose(served, before: before(note_card("Own note."), inherited: LENT_NOTE))
+    assert_equal 1, sentry_messages.length
+  end
+
+  # A nameless group's label is its id, and a group named the same
+  # lends under it too: a section read under that label cannot say
+  # which lent it, so nothing is attributed.
+  def test_two_groups_lending_under_one_label_are_not_attributed
+    same_label = ProTacts::Group.new(
+      id: "Neighbours", name: nil, label: "Neighbours", lines: ["NOTE:other"], members: ["aiden"],
+    )
+    lent = [*LENT_NOTE, ProTacts::Contact::Inherited.new(group: same_label, position: 2, line: "NOTE:other")]
+    served = note_card("Own note.\n\nShared · Neighbours:\n#{NOTE_TEXT}")
+
+    assert_equal [served, nil, []], decompose(served, before: before(note_card("Own note."), inherited: lent))
+    assert_equal ["two of a contact's groups share a label, so its submitted NOTE cannot be attributed"],
+                 sentry_messages
+  end
+
+  # Foreign input, no lending: a client's two-NOTE card joins into one
+  # value at the first line's position and is reported — joined rather
+  # than refused, the invariant's answer to a card this server did not
+  # compose.
+  def test_two_own_note_lines_join_into_one_and_are_reported
+    submitted = with_lines("NOTE:First", "NOTE:Second")
+
+    assert_equal [with_lines("NOTE:First\\n\\nSecond"), nil, []], decompose(submitted, before: before)
+    assert_equal ["a submitted card carried 2 NOTE lines"], sentry_messages
+  end
+
+  # The two halves at once: a lent line subtracted as a line and a
+  # lent note as sections, the card that comes back holding neither.
+  def test_lent_lines_and_sections_leave_together
+    stored = note_card("Own note.")
+    served = note_card("Own note.\n\nShared · Neighbours:\n#{NOTE_TEXT}")
+      .sub("END:VCARD\r\n", "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n")
+
+    assert_equal(
+      [stored, nil, []],
+      decompose(served, before: before(stored, inherited: [*LENT, *LENT_NOTE])),
+    )
+  end
 end
diff --git a/test/pro_tacts/test_note_sections.rb b/test/pro_tacts/test_note_sections.rb
new file mode 100644
index 0000000..355414e
--- /dev/null
+++ b/test/pro_tacts/test_note_sections.rb
@@ -0,0 +1,119 @@
+require_relative "../test_helper"
+
+require "pro_tacts/note_sections"
+
+# The text algebra of the one NOTE a served card carries, on its own
+# with no contact and no group: what composition writes and what a PUT
+# reads, per docs/plans/2026-09-25-one-note-per-contact.md. The join
+# and the split it reads with are tested together where they agree, and
+# each alone where they do not — the split tolerating the whitespace a
+# person's editing moves, the join writing one shape only.
+class NoteSectionsTest < Minitest::Test
+  NEIGHBOURS = ProTacts::NoteSections.header("Neighbours") #: String
+
+  def section(label, text) = ProTacts::NoteSections::Section.new(label:, text:)
+
+  def split(value, labels) = ProTacts::NoteSections.split(value, labels)
+
+  ## The join
+
+  def test_member_text_and_one_section
+    sections = [section("Neighbours", "bins go out on Tuesday")]
+
+    assert_equal "gate code 1854\n\n#{NEIGHBOURS}\nbins go out on Tuesday",
+      ProTacts::NoteSections.join("gate code 1854", sections)
+  end
+
+  def test_a_section_alone_loses_no_blank_it_never_had
+    assert_equal "#{NEIGHBOURS}\nbins go out on Tuesday",
+      ProTacts::NoteSections.join("", [section("Neighbours", "bins go out on Tuesday")])
+  end
+
+  def test_sections_come_in_the_order_given
+    value = ProTacts::NoteSections.join(
+      "",
+      [section("Neighbours", "bins"), section("Dog walkers", "Sunday")],
+    )
+
+    assert_equal "#{NEIGHBOURS}\nbins\n\n#{ProTacts::NoteSections.header('Dog walkers')}\nSunday", value
+  end
+
+  def test_a_multiline_body_keeps_its_paragraphs
+    value = ProTacts::NoteSections.join("own", [section("Neighbours", "first line\n\nsecond line")])
+
+    assert_equal "own\n\n#{NEIGHBOURS}\nfirst line\n\nsecond line", value
+  end
+
+  def test_a_bodyless_section_is_its_header
+    assert_equal "#{NEIGHBOURS}", ProTacts::NoteSections.join("", [section("Neighbours", "")])
+  end
+
+  ## The split
+
+  def test_the_joined_shape_reads_back
+    value = ProTacts::NoteSections.join(
+      "gate code 1854\ncall before visiting",
+      [section("Neighbours", "bins go out on Tuesday\ngreen bin alternates")],
+    )
+
+    read = split(value, ["Neighbours"])
+
+    assert_equal "gate code 1854\ncall before visiting", read.member
+    assert_equal [section("Neighbours", "bins go out on Tuesday\ngreen bin alternates")], read.sections
+  end
+
+  def test_a_label_nothing_lends_is_member_text
+    read = split("pastedin\n#{NEIGHBOURS}\nnot lent", [])
+
+    assert_equal "pastedin\n#{NEIGHBOURS}\nnot lent", read.member
+    assert_empty read.sections
+  end
+
+  def test_whitespace_between_sections_is_not_structure
+    read = split("own\n#{NEIGHBOURS}\nlent\n\n\n", ["Neighbours"])
+
+    assert_equal "own", read.member
+    assert_equal [section("Neighbours", "lent")], read.sections
+  end
+
+  def test_a_header_tolerates_trailing_whitespace
+    read = split("own\n#{NEIGHBOURS}  \nlent", ["Neighbours"])
+
+    assert_equal [section("Neighbours", "lent")], read.sections
+  end
+
+  def test_an_indented_header_is_member_text
+    read = split("own\n #{NEIGHBOURS}\nlent", ["Neighbours"])
+
+    assert_equal "own\n #{NEIGHBOURS}\nlent", read.member
+    assert_empty read.sections
+  end
+
+  def test_blank_lines_around_the_member_are_the_separator
+    read = split("\n\nown\n\n#{NEIGHBOURS}\nlent", ["Neighbours"])
+
+    assert_equal "own", read.member
+    assert_equal [section("Neighbours", "lent")], read.sections
+  end
+
+  def test_a_member_of_all_blanks_is_no_member
+    read = split("\n\n#{NEIGHBOURS}\nlent", ["Neighbours"])
+
+    assert_equal "", read.member
+  end
+
+  def test_two_sections_of_one_label_stay_two
+    read = split("#{NEIGHBOURS}\nfirst\n\n#{NEIGHBOURS}\nsecond", ["Neighbours"])
+
+    assert_equal [section("Neighbours", "first"), section("Neighbours", "second")], read.sections
+  end
+
+  def test_sections_read_in_the_order_they_stand
+    read = split(
+      "#{ProTacts::NoteSections.header('Dog walkers')}\nSunday\n\n#{NEIGHBOURS}\nbins",
+      ["Neighbours", "Dog walkers"],
+    )
+
+    assert_equal [section("Dog walkers", "Sunday"), section("Neighbours", "bins")], read.sections
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index fab9270..d00825d 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1141,16 +1141,19 @@ class StoreTest < Minitest::Test
 
   # A member's served card is the stored one with the group's lines
   # composed in, and the birthday composed after them — every read,
-  # single or listed, hands out the same composed card.
+  # single or listed, hands out the same composed card. A lent note
+  # composes as a section of the one NOTE, headed by the group's
+  # label — its id, this group being nameless
+  # (docs/plans/2026-09-25-one-note-per-contact.md).
   def test_a_member_serves_the_group_lines_composed_in
     born = AIDEN.sub("FN:Aiden\r\n", "FN:Aiden\r\nBDAY:1985-12-10\r\n")
-    composed = AIDEN.sub(
-      "END:VCARD\r\n",
-      "#{HOUSEHOLD_ADDRESS}\r\n#{HOUSEHOLD_NOTE}\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n",
-    )
 
     with_store({"aiden" => born, "znorth" => ZED}) do |store|
-      FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+      id = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+      composed = AIDEN.sub(
+        "END:VCARD\r\n",
+        "#{HOUSEHOLD_ADDRESS}\r\nNOTE:Shared · #{id}:\\nGate code 1854.\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n",
+      )
 
       assert_equal composed, store.contact("aiden").vcard.to_s
       assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
@@ -1186,21 +1189,26 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # Two groups' lines compose in group-id order, then position — the
-  # order is a fact of the schema, not of whichever join SQLite
+  # Two groups' sections compose in group-id order, then position —
+  # the order is a fact of the schema, not of whichever join SQLite
   # returns first, so the composed bytes never move between reads. The
   # ids are minted rather than chosen, so the expectation is sorted by
   # them: which group leads is the ids' business, that the id decides
-  # it rather than the creation order is this test's.
+  # it rather than the creation order is this test's. Both groups are
+  # nameless, so each section is headed by the id that ordered it.
   def test_two_groups_compose_in_a_fixed_order
     with_store({"aiden" => AIDEN}) do |store|
-      lent = {
-        FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_NOTE]) => HOUSEHOLD_NOTE,
-        FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS]) => HOUSEHOLD_ADDRESS,
+      lent = [HOUSEHOLD_NOTE, "NOTE:Second note."].to_h { |line|
+        [FixtureData.seed_group(store, members: ["aiden"], lines: [line]), line]
       }
 
-      first, second = lent.sort.map { it.last }
-      composed = AIDEN.sub("END:VCARD\r\n", "#{first}\r\n#{second}\r\nEND:VCARD\r\n")
+      first, second = lent.keys.sort
+      text = -> gid { lent.fetch(gid).split(":", 2).fetch(1) }
+      value = "Shared · #{first}:\n#{text.(first)}\n\nShared · #{second}:\n#{text.(second)}"
+      composed = AIDEN.sub(
+        "END:VCARD\r\n",
+        "NOTE:#{ProTacts::VCard.escape(value)}\r\nEND:VCARD\r\n",
+      )
       assert_equal composed, store.contact("aiden").vcard.to_s
     end
   end
@@ -1242,6 +1250,31 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The note-bearing member's round trip, the one the one-note shape
+  # exists for: a client PUTs back the served card and the member's
+  # own note is what is stored — the group's section gone, nothing
+  # propagated, the composed etag standing — so a client that changed
+  # nothing hears of nothing (docs/plans/2026-09-25-one-note-per-
+  # contact.md).
+  def test_a_note_bearing_members_served_card_round_trips
+    stored = AIDEN.sub("END:VCARD\r\n", "NOTE:Own note.\r\nEND:VCARD\r\n")
+
+    with_store({"aiden" => stored}) do |store|
+      id = FixtureData.seed_group(store, name: "Booles", members: ["aiden"], lines: [HOUSEHOLD_NOTE])
+
+      served = store.contact("aiden")
+      put = store.put("aiden", vcard(served.vcard.to_s))
+      change = store.changes_of("aiden").first
+
+      assert_includes put.vcard.to_s, "NOTE:Own note.\\n\\nShared · Booles:\\nGate code 1854.\r\n"
+      assert_equal stored, put.stored.to_s
+      assert_equal [HOUSEHOLD_NOTE], store.group(id).lines
+      assert_equal served.etag, change.etag
+      assert_empty change.diff.added
+      assert_empty change.diff.removed
+    end
+  end
+
   # Every inherited line carries the group lending it, whole, on the
   # single read and on the listing read that composes a whole
   # collection — a screen tags a row as the household's from either.
@@ -1411,11 +1444,37 @@ class StoreTest < Minitest::Test
         change = store.changes_of(member).first
         assert_equal "group", change.action
         assert_equal store.contact(member).etag, change.etag
-        assert_equal [EDITED_ADDRESS, HOUSEHOLD_NOTE], change.diff.added
+        assert_equal [EDITED_ADDRESS, "NOTE:Shared · #{id}:\\nGate code 1854."], change.diff.added
       end
     end
   end
 
+  # The one-note invariant's raise: lines this server built carrying
+  # two NOTEs are a bug, and the raise reaches Sentry with the request
+  # (docs/plans/2026-09-25-one-note-per-contact.md, "The invariant").
+  def test_a_group_may_not_hold_two_notes
+    with_store({"aiden" => AIDEN}) do |store|
+      id = FixtureData.seed_group(store, members: ["aiden"])
+
+      error = assert_raises(RuntimeError) {
+        store.set_group_lines(id, [HOUSEHOLD_NOTE, "NOTE:Second."])
+      }
+      assert_equal "a group was given 2 NOTE lines", error.message
+    end
+  end
+
+  # The same raise over an editor's save — foreign input joins on its
+  # way in (EditedContact, Import::Merge), so two NOTEs here can only
+  # be a path this server built.
+  def test_an_editors_save_may_not_store_two_notes
+    with_store({"aiden" => AIDEN}) do |store|
+      two = AIDEN.sub("END:VCARD\r\n", "NOTE:First.\r\nNOTE:Second.\r\nEND:VCARD\r\n")
+
+      error = assert_raises(RuntimeError) { store.save_edit("aiden", vcard(two), birthday: nil) }
+      assert_equal "a stored card was built with 2 NOTE lines", error.message
+    end
+  end
+
   def test_a_joining_member_serves_the_group_and_is_logged
     with_store({"aiden" => AIDEN}) do |store|
       id = FixtureData.seed_group(store, members: [], lines: [HOUSEHOLD_ADDRESS])
@@ -1740,7 +1799,7 @@ class StoreTest < Minitest::Test
 
       contact = store.put("aiden", vcard(AIDEN), client: true)
 
-      assert_includes contact.vcard.to_s, HOUSEHOLD_NOTE
+      assert_includes contact.vcard.to_s, "NOTE:Shared · sync:*:\\nGate code 1854."
       assert_equal store.contact("aiden").etag, store.changes_of("aiden").first.etag
     end
   end
@@ -2320,6 +2379,46 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # A database from before the one-note invariant is joined onto it:
+  # a card's several NOTEs become one value, a group's several NOTE
+  # rows become the one row a member's edit writes back to, and a
+  # card holding one note all along keeps its bytes. The member
+  # serves the group's joined note as one section, the shape every
+  # write composes from here
+  # (docs/plans/2026-09-25-one-note-per-contact.md).
+  def test_the_migration_joins_several_notes_into_one
+    two_notes = AIDEN.sub("END:VCARD\r\n", "NOTE:First.\r\nNOTE:Second.\r\nEND:VCARD\r\n")
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+      Sequel.connect("sqlite://#{path}") do |db|
+        Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 11)
+        db[:cards].insert(id: "aiden", vcard: two_notes)
+        db[:cards].insert(id: "znorth", vcard: ZED.sub("END:VCARD\r\n", "NOTE:One only.\r\nEND:VCARD\r\n"))
+        db[:groups].insert(id: "nous", name: "Booles")
+        db[:group_properties].insert(group_id: "nous", position: 0, line: HOUSEHOLD_ADDRESS)
+        db[:group_properties].insert(group_id: "nous", position: 1, line: "NOTE:Gate code 1854.")
+        db[:group_properties].insert(group_id: "nous", position: 2, line: "NOTE:Bins on Tuesday.")
+        db[:group_members].insert(group_id: "nous", card_id: "znorth")
+      end
+
+      ProTacts::Store.connect(path) do |store|
+        joined = AIDEN.sub("END:VCARD\r\n", "NOTE:First.\\n\\nSecond.\r\nEND:VCARD\r\n")
+        assert_equal joined, store.contact("aiden").vcard.to_s
+
+        assert_equal(
+          [HOUSEHOLD_ADDRESS, "NOTE:Gate code 1854.\\n\\nBins on Tuesday."],
+          store.group("nous").lines,
+        )
+        served = ZED.sub(
+          "END:VCARD\r\n",
+          "NOTE:One only.\\n\\nShared · Booles:\\nGate code 1854.\\n\\nBins on Tuesday.\r\n" \
+          "#{HOUSEHOLD_ADDRESS}\r\nEND:VCARD\r\n",
+        )
+        assert_equal served, store.contact("znorth").vcard.to_s
+      end
+    end
+  end
+
   def test_a_snapshot_holds_the_stored_state
     with_store({"aiden" => AIDEN_BORN}) do |store|
       group = store.create_group(name: "Booles")