contact: carry the group that lends each inherited line
A borrowed line arrived with nobody's name attached;
now the group comes with it, so a row can say who lent.
And NOTE reads every line the card has, not the first,
since a member with its own still inherits the household's.

Assisted-by: Claude Opus 5 via Claude Code
change lmqpqvsozvlxrulurvmvtxukxtnlvmrl
commit faced2850566b918fd9e518e9eab2fc7dbb9996b
author Alpha Chen <alpha@kejadlen.dev>
date
parent ozrmqnvw
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index ff85bde..176d50e 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -175,10 +175,13 @@ module ProTacts
                   @contact.addresses.each { address_row(it) }
                   birthday_row
                   added_rows
-                  label(class: "field", data: {blank_removes: !!@contact.notes}) do
+                  # The first NOTE the card carries: this row edits one,
+                  # and a save writes one (Web#edited_card's replace).
+                  note = @contact.notes.first&.value
+                  label(class: "field", data: {blank_removes: !!note}) do
                     span { "Note" }
                     textarea(name: "note", rows: 4,
-                             placeholder: @contact.notes ? "removed on save" : nil) { @contact.notes.to_s }
+                             placeholder: note ? "removed on save" : nil) { note.to_s }
                   end
                   # Save is the form's submit; Cancel is navigation — a
                   # link in Gloss's `.btn` contract, which is what an
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 3ad26f0..0c3103e 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -93,7 +93,7 @@ module ProTacts
       #: () -> bool
       def has_data?
         @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
-          !@birthday.nil? || !@contact.notes.nil?
+          !@birthday.nil? || @contact.notes.any?
       end
 
       # A missing TYPE parameter still gets a key: the fallback names
@@ -103,7 +103,7 @@ module ProTacts
         @contact.emails.each { |email| row(email.type || "email", email.value) }
         @contact.addresses.each { |address| row(address.type || "address", address_lines(address)) }
         row("birthday", @birthday) if @birthday
-        row("notes", @contact.notes) if @contact.notes
+        @contact.notes.each { row("notes", it.value) }
       end
 
       #: (String type, String | Array[String] value) -> void
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 4a9415d..0a84a5b 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -52,9 +52,10 @@ module ProTacts
     # @rbs @id: String
     # @rbs @stored: VCard
     # @rbs @birthday: Birthday?
-    # @rbs @inherited: Array[String]
+    # @rbs @inherited: Array[Inherited]
     # @rbs @vcard: VCard
     # @rbs @etag: String
+    # @rbs @groups_by_line: Hash[String, String]
 
     # Ids end up in paths and arrive from client-supplied hrefs, so an id
     # outside this charset cannot be served.
@@ -77,7 +78,17 @@ module ProTacts
     # @rbs skip
     Email = Data.define(:value, :type, :line)
     # @rbs skip
+    Note = Data.define(:value, :line)
+    # @rbs skip
     Photo = Data.define(:mime_type, :bytes)
+
+    # One line a group lends this contact, and the name of the group
+    # that lends it. The name rather than the group itself, because
+    # the name is the whole of what a card shows about it today and
+    # nothing here can navigate to a record that has no screen yet
+    # (docs/DESIGN.md, "Relationships are navigable").
+    # @rbs skip
+    Inherited = Data.define(:group, :line)
     # @rbs skip
     Address = Data.define(
       :po_box,
@@ -102,7 +113,7 @@ module ProTacts
     # member its group's lines no longer reach. Named `stored:` rather
     # than `vcard:` because what #vcard returns is composed, and an
     # argument that is not what the reader hands back is a trap.
-    #: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[String]) -> Contact
+    #: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[Inherited]) -> Contact
     def self.for(id:, stored:, birthday:, inherited:)
       raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
 
@@ -114,7 +125,7 @@ module ProTacts
       %("#{Digest::SHA256.hexdigest(vcard.to_s)}")
     end
 
-    #: (String id, VCard stored, Birthday? birthday, Array[String] inherited) -> void
+    #: (String id, VCard stored, Birthday? birthday, Array[Inherited] inherited) -> void
     def initialize(id, stored, birthday, inherited)
       @id = id
       @stored = stored
@@ -152,7 +163,7 @@ module ProTacts
     def vcard
       return @vcard if defined?(@vcard)
 
-      card = @stored.insert(@inherited)
+      card = @stored.insert(@inherited.map { it.line })
       line = @birthday && @birthday.to_line
       @vcard = line ? card.insert([line]) : card
     end
@@ -287,14 +298,55 @@ module ProTacts
       end
     end
 
-    # The card's NOTE (RFC 2426 section 3.6.2), in text form.
-    #: () -> String?
+    # 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).
+    #: () -> Array[Note]
     def notes
-      text_of(properties.find { it.name.casecmp?("NOTE") })
+      of_line("NOTE").filter_map do |line|
+        property = line.property
+        next if property.nil?
+
+        value = text_of(property)
+        Note.new(value:, line:) if value
+      end
+    end
+
+    # The name of the group a composed line came from, or nil for a
+    # line the contact's own card carries — the provenance a screen
+    # marks an inherited row with.
+    #
+    # Matched by the line's bytes, which is the same blindness the
+    # editor's digests have (VCard::Parser::Line#digest): a stored
+    # line whose bytes are a group's line reads as the group's here.
+    # That state is a member's rewrite carrying an inherited line back
+    # into its own card, which the write half subtracts
+    # (docs/plans/2026-08-24-vcard-storage-and-groups.md); until it
+    # lands, naming the group over both copies says something true
+    # about the value even where it is wrong about the byte.
+    #: (VCard::Parser::Line line) -> String?
+    def group_of(line)
+      groups_by_line[line.verbatim.chomp]
     end
 
     private
 
+    # Every inherited line's group, keyed by the line's own bytes with
+    # the terminator off — a group's line is stored without one and
+    # composes with the card's, so neither side is compared as it lies.
+    #: () -> Hash[String, String]
+    def groups_by_line
+      return @groups_by_line if defined?(@groups_by_line)
+
+      @groups_by_line = @inherited.to_h {
+        [it.line.chomp, it.group] #: [String, String]
+      }
+    end
+
     # The lines naming `name`, parsed beside their bytes — the walk the
     # provenance-carrying accessors read from, so a row can carry the
     # line it was read from as its address. A line that would not
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 0ffe85f..2dd7294 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -523,7 +523,7 @@ module ProTacts
     # Contact composes the served one — its vcard, its etag, everything
     # a caller reads — with the inherited lines beside it, so all
     # describe what a client downloads rather than the bytes on disk.
-    #: (Hash[Symbol, untyped] row, Birthday? birthday, Array[String] inherited) -> Contact
+    #: (Hash[Symbol, untyped] row, Birthday? birthday, Array[Contact::Inherited] inherited) -> Contact
     def contact_from(row, birthday, inherited)
       Contact.for(
         id: row.fetch(:id).to_s,
@@ -569,13 +569,14 @@ module ProTacts
       row && birthday_from(row)
     end
 
-    # The content lines a contact inherits — every property of every
-    # group it belongs to, nothing of a group that holds nothing, and
-    # [] for a contact in no group at all. Ordered by group id and then
-    # position, so the composed card is the same bytes every read.
-    #: (String id) -> Array[String]
+    # The content lines a contact inherits, each beside the name of the
+    # group lending it — every property of every group it belongs to,
+    # nothing of a group that holds nothing, and [] for a contact in no
+    # group at all. Ordered by group id and then position, so the
+    # composed card is the same bytes every read.
+    #: (String id) -> Array[Contact::Inherited]
     def inherited_of(id)
-      inherited_rows.where(card_id: id).map { it.fetch(:line).to_s }
+      inherited_rows.where(card_id: id).map { inherited_from(it) }
     end
 
     # Every contact's inheritance in one pass, keyed by card, for the
@@ -583,21 +584,30 @@ module ProTacts
     # birthdays_by_id, for the same reason. Only a contact with
     # something to inherit appears; the absent key reads as [] at the
     # caller, which is what it means.
-    #: () -> Hash[String, Array[String]]
+    #: () -> Hash[String, Array[Contact::Inherited]]
     def inherited_by_id
       inherited_rows.all
         .group_by { it.fetch(:card_id).to_s }
-        .transform_values { |rows| rows.map { it.fetch(:line).to_s } }
+        .transform_values { |rows| rows.map { inherited_from(it) } }
     end
 
     # The join both inherited reads walk: a membership to the property
-    # it inherits, qualified and ordered so the composition neither
-    # depends on what SQLite feels like returning nor trips over the
-    # group_id the two tables share.
+    # it inherits and to the group's own row for its name, qualified
+    # and ordered so the composition neither depends on what SQLite
+    # feels like returning nor trips over the group_id the tables
+    # share. Ordered by the group's id rather than its name, because a
+    # rename must not move a member's lines and change every etag in
+    # the group.
     #: () -> Sequel::Dataset
     def inherited_rows
       group_members
         .join(:group_properties, group_id: :group_id)
+        .join(:groups, id: Sequel[:group_members][:group_id])
+        .select(
+          Sequel[:group_members][:card_id],
+          Sequel[:groups][:name].as(:group_name),
+          Sequel[:group_properties][:line],
+        )
         .order(
           Sequel[:group_members][:card_id],
           Sequel[:group_properties][:group_id],
@@ -605,6 +615,11 @@ module ProTacts
         )
     end
 
+    #: (Hash[Symbol, untyped] row) -> Contact::Inherited
+    def inherited_from(row)
+      Contact::Inherited.new(group: row.fetch(:group_name).to_s, line: row.fetch(:line).to_s)
+    end
+
     # A birthday row read as the model. The shape was validated on the
     # way in, so a row that no longer parses is corruption to raise on
     # rather than quietly drop.
@@ -616,7 +631,7 @@ module ProTacts
     # One joined birthday-and-card row as an UpcomingBirthday, or nil
     # for a shape that lands on no calendar day (see
     # #upcoming_birthdays).
-    #: (Hash[Symbol, untyped] row, Date today, Array[String] inherited) -> UpcomingBirthday?
+    #: (Hash[Symbol, untyped] row, Date today, Array[Contact::Inherited] inherited) -> UpcomingBirthday?
     def upcoming_from(row, today, inherited)
       birthday = birthday_from(row)
       month, day = birthday.month, birthday.day
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index bf631a6..c3f102e 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -78,6 +78,10 @@ module Sequel
 
     def where: (*untyped conditions) -> Dataset
     def order: (*untyped columns) -> Dataset
+    # The columns a joined dataset comes back with, named rather than
+    # merged by whichever table's column won. Signed here so it reads
+    # as the query builder's rather than Enumerable's own #select.
+    def select: (*untyped columns) -> Dataset
     def limit: (Integer count) -> Dataset
     # Hash conditions pair the joined table's column with the source's
     # (`id: :card_id` is cards.id = birthdays.card_id); the array form
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 63a5576..3bbdaf3 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,4 +1,5 @@
-# Contact::Phone, ::Email, ::Photo, and ::Address are Data classes, whose
+# Contact::Phone, ::Email, ::Note, ::Inherited, ::Photo, and ::Address are
+# Data classes, whose
 # members the inline parser cannot read (see sig/pro_tacts/birthday.rbs
 # for the same limit). Each is marked `@rbs skip` in contact.rb and
 # signed here; Contact itself is a plain class and carries its
@@ -21,6 +22,20 @@ module ProTacts
       def self.new: (value: String, type: String?, line: VCard::Parser::Line) -> instance
     end
 
+    class Note
+      attr_reader value: String
+      attr_reader line: VCard::Parser::Line
+
+      def self.new: (value: String, line: VCard::Parser::Line) -> instance
+    end
+
+    class Inherited
+      attr_reader group: String
+      attr_reader line: String
+
+      def self.new: (group: String, line: String) -> instance
+    end
+
     class Photo
       attr_reader mime_type: String
       attr_reader bytes: String
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 469359e..29fc308 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -24,8 +24,14 @@ class ContactTest < Minitest::Test
   # passes a card carrying an unmodeled BDAY beside nil — no stored
   # card ever carries a BDAY the model recomposes — and inheritance
   # defaults to none, the shape of every contact in no group.
-  def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [])
-    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited:)
+  # Inherited lines are given as bare strings and lent by GROUP, the
+  # name a composed row is marked with; a test that needs a second
+  # lender passes its own.
+  GROUP = "Boole household" #: String
+
+  def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
+    lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
+    ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:, inherited: lent)
   end
 
   # The decode's refusal reports (see #photo); the transport this pins
@@ -228,7 +234,7 @@ class ContactTest < Minitest::Test
     assert_empty contact.phones
     assert_empty contact.emails
     assert_empty contact.addresses
-    assert_nil contact.notes
+    assert_empty contact.notes
   end
 
   # The birthday is the model held beside the card, not a parse of
@@ -310,8 +316,34 @@ class ContactTest < Minitest::Test
     assert_equal "+44 20 5555 0100", contact(card, inherited: ["TEL;TYPE=home:+44 20 5555 0100"]).phones.first&.value
   end
 
+  # Which lines a group lends is the composed card's own question:
+  # a row a screen marks as shared asks it per line, and a line the
+  # stored card carries answers nil however much it looks inherited.
+  def test_a_line_names_the_group_that_lends_it
+    lent = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom"
+    contact = contact(STRUCTURED, inherited: [lent])
+    addresses = contact.addresses
+
+    assert_equal 2, addresses.size
+    assert_nil contact.group_of(addresses.fetch(0).line)
+    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 }
+    assert_nil contact.group_of(notes.fetch(0).line)
+    assert_equal GROUP, contact.group_of(notes.fetch(1).line)
+  end
+
   def test_unescapes_notes
-    assert_equal "Countess, mathematician.", contact(STRUCTURED).notes
+    assert_equal ["Countess, mathematician."], contact(STRUCTURED).notes.map { it.value }
   end
 
   def test_properties_are_the_parsed_card
@@ -331,7 +363,7 @@ class ContactTest < Minitest::Test
     assert_empty bare.emails
     assert_empty bare.addresses
     assert_nil bare.birthday
-    assert_nil bare.notes
+    assert_empty bare.notes
   end
 
   # A card that will not read is still a contact: the bytes are what
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index d695431..a4893c6 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1047,6 +1047,20 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # Every inherited line carries the name of the group lending it, on
+  # the single read and on the listing read that composes a whole
+  # collection — a screen marks a row as the household's from either.
+  def test_an_inherited_line_carries_its_groups_name
+    with_store({"aiden" => AIDEN}) do |store|
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+      listed = store.contacts.find { it.id == "aiden" }
+      [store.contact("aiden"), listed].each do |contact|
+        assert_equal "Household", contact.group_of(contact.addresses.fetch(0).line)
+      end
+    end
+  end
+
   # The schema is the only gate on what a group may hold until
   # authoring exists, so what it admits is worth pinning: an address
   # or a note, bare or parameterized, in whatever case the author