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
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")