groups: hold only an address and a note
A group took any line its author cared to write;
a phone rings one person, so the CHECK now shuts it tight.
ADR and NOTE ride through in any case they came,
and since a pragma owns LIKE's casing, the CHECK folds the name.

Assisted-by: Claude Opus 5 via Claude Code
change ozrmqnvwspyqlrxpspnlyynrvstrorrx
commit 4b8e1dd98b31afc567071a41e5697e496df42d61
author Alpha Chen <alpha@kejadlen.dev>
date
parent rrqstrqm
diff --git a/db/migrations/004_groups.rb b/db/migrations/004_groups.rb
index c4946da..e0f27b7 100644
--- a/db/migrations/004_groups.rb
+++ b/db/migrations/004_groups.rb
@@ -3,9 +3,17 @@
 # server-side state — membership is never exposed to the client — and
 # the properties it holds are verbatim content lines rather than parsed
 # structure, for the same reason the cards are stored as bytes: what
-# the group's author wrote is what a member's card carries, and a
-# property this server does not model rides through composition
-# untouched (RFC 6352 section 6.3.2.2).
+# the group's author wrote is what a member's card carries, down to the
+# spelling this server would not have chosen.
+#
+# Which properties a group may hold is a narrower question than what a
+# line may say, and the answer for now is an address and a note: the
+# attributes a household actually shares. A shared TEL or EMAIL reaches
+# a person rather than the household, and the composition has no answer
+# yet for the properties macOS builds out of two lines — a labeled
+# `item1.ADR` needs its `item1.X-ABLabel` beside it, and a constraint
+# over one line at a time cannot hold a pair together. The names widen
+# when something needs them to; the CHECK below is where.
 #
 # No timestamps on these tables, the birthdays' own reason: the rows
 # are current state, replaced wholesale by whatever authoring last
@@ -13,10 +21,27 @@
 # 'group' action below — is the history of a group's edits.
 #
 # `text: true` on every string column because these are STRICT tables,
-# which refuse Sequel's plain String. A local rather than a constant,
+# which refuse Sequel's plain String. Locals rather than constants,
 # for the reason 001 records.
 now = "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
 
+# A shareable line, as the two forms one can take: `ADR:` bare and
+# `ADR;TYPE=home:` parameterized. A grouped spelling — `item1.ADR:` —
+# starts with neither and is refused, which is the intent above.
+#
+# The name is folded rather than matched as written, because `FN` and
+# `fn` are one name (RFC 6350 section 3.3, which RFC 2426 leaves
+# unsaid) and because LIKE's own case-sensitivity is not this
+# constraint's to assume: SQLite matches case-insensitively over ASCII
+# by default, Sequel's adapter turns that off with a
+# `case_sensitive_like` pragma, and a pragma is per-connection — so a
+# CHECK resting on LIKE alone would admit a lowercase line through one
+# connection and refuse it through the next.
+folded = Sequel.function(:upper, :line)
+shareable = %w[ADR NOTE].flat_map { |name|
+  [Sequel.like(folded, "#{name}:%"), Sequel.like(folded, "#{name};%")]
+}
+
 Sequel.migration do
   change do
     # A group's own row: an id and the name the admin surfaces show.
@@ -28,12 +53,16 @@ Sequel.migration do
 
     # What a group contributes, as the content lines to compose into a
     # member's card. `position` is the line's place in the group, which
-    # fixes the order the composition serves them in.
+    # fixes the order the composition serves them in. The CHECK is the
+    # only gate on what a group may hold — nothing public writes this
+    # table yet, so a name outside the shareable set has to be refused
+    # by the schema or not at all.
     create_table(:group_properties, strict: true) do
       foreign_key :group_id, :groups, type: String, text: true, null: false, on_delete: :cascade
       Integer :position, null: false
       String :line, text: true, null: false
       primary_key [:group_id, :position]
+      constraint(:line_is_shareable, Sequel.|(*shareable))
     end
 
     # Membership. Deleting a card drops the membership — the group
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index ee1c27a..2fe8e1b 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -37,23 +37,24 @@ module FixtureData
     CARDS.glob("*.vcf").sort.to_h { [it.basename(".vcf").to_s, it.read] }
   end
 
-  # What the household shares: an address and a home number, the
+  # What the household shares: an address and a note, which is both
+  # what a group may hold (see db/migrations/004_groups.rb) and the
   # shape the groups work tests against
   # (docs/plans/2026-08-24-vcard-storage-and-groups.md). Deliberately
   # not the address any seed card already carries, so a test can tell
   # a composed line from a stored one by its bytes.
   HOUSEHOLD = [
     "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
-    "TEL;TYPE=home:+44 20 5555 0100",
+    "NOTE:Gate code 1854. The dog is friendly\\, the goose is not.",
   ].freeze #: Array[String]
 
   # The groups the fixture book carries: one household, the three
   # household-* cards in it. Those cards are a family — one surname, a
-  # mobile and an email each, and no address or home number of their
-  # own — so the only home address the admin UI shows on them is the
-  # one this group composes in, 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.
+  # 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.
   #
   # Seeded in Ruby rather than a fixture file because the cards are the
   # only evidence-shaped fixtures — a group is rows across three
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 816ca3a..4fe4b06 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -116,7 +116,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-alicia.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6c01a910f23f1cad35baec49312f787d0cb4c324f3b4cdd9b826536b87c8445a"</d:getetag>
+      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -125,7 +125,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-george.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"2dcd92c33d147687f4a669a2b3f4dba19bb9f6a093ebc990a054926294864091"</d:getetag>
+      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -134,7 +134,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-mary.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"d6c544cb829cbf1be3fbbd8e2b47b752c4e4659c45c4d233613b543b9db6517f"</d:getetag>
+      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index cf250bb..6fe1be0 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -106,7 +106,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-alicia.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6c01a910f23f1cad35baec49312f787d0cb4c324f3b4cdd9b826536b87c8445a"</d:getetag>
+      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -115,7 +115,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-george.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"2dcd92c33d147687f4a669a2b3f4dba19bb9f6a093ebc990a054926294864091"</d:getetag>
+      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -124,7 +124,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-mary.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"d6c544cb829cbf1be3fbbd8e2b47b752c4e4659c45c4d233613b543b9db6517f"</d:getetag>
+      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</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 9e740d2..141f3a1 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -141,7 +141,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-alicia.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"6c01a910f23f1cad35baec49312f787d0cb4c324f3b4cdd9b826536b87c8445a"</d:getetag>
+      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -150,7 +150,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-george.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"2dcd92c33d147687f4a669a2b3f4dba19bb9f6a093ebc990a054926294864091"</d:getetag>
+      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -159,7 +159,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/household-mary.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"d6c544cb829cbf1be3fbbd8e2b47b752c4e4659c45c4d233613b543b9db6517f"</d:getetag>
+      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 89667fa..d695431 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -939,6 +939,7 @@ class StoreTest < Minitest::Test
   ## Groups
 
   HOUSEHOLD_ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+  HOUSEHOLD_NOTE = "NOTE:Gate code 1854." #: String
 
   # There is no write path for the group tables yet — authoring is the
   # admin UI's task — so the tests arrange them the way the fixture
@@ -957,11 +958,11 @@ class StoreTest < Minitest::Test
     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\nTEL;TYPE=home:+44 20 5555 0100\r\nBDAY:1985-12-10\r\nEND: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|
-      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, "TEL;TYPE=home:+44 20 5555 0100"])
+      add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
 
       assert_equal composed, store.contact("aiden").vcard.to_s
       assert_equal composed, store.contacts.find { it.id == "aiden" }.vcard.to_s
@@ -1002,12 +1003,12 @@ class StoreTest < Minitest::Test
   # returns first, so the composed bytes never move between reads.
   def test_two_groups_compose_in_a_fixed_order
     with_store({"aiden" => AIDEN}) do |store|
-      add_group(store, id: "zedly", members: ["aiden"], lines: ["TEL;TYPE=home:+44 20 5555 0100"])
+      add_group(store, id: "zedly", members: ["aiden"], lines: [HOUSEHOLD_NOTE])
       add_group(store, id: "household", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       composed = AIDEN.sub(
         "END:VCARD\r\n",
-        "#{HOUSEHOLD_ADDRESS}\r\nTEL;TYPE=home:+44 20 5555 0100\r\nEND:VCARD\r\n",
+        "#{HOUSEHOLD_ADDRESS}\r\n#{HOUSEHOLD_NOTE}\r\nEND:VCARD\r\n",
       )
       assert_equal composed, store.contact("aiden").vcard.to_s
     end
@@ -1046,6 +1047,31 @@ class StoreTest < Minitest::Test
     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
+  # spelled the name. Everything else is refused — a phone or an email
+  # reaches a person rather than a household, and a grouped ADR is half
+  # of a labeled property whose other half no group may hold.
+  def test_a_group_holds_only_addresses_and_notes
+    with_store({"aiden" => AIDEN}) do |store|
+      properties = database(store)[:group_properties]
+      database(store)[:groups].insert(id: "household", name: "Household")
+
+      admitted = [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE, "ADR:;;1 Long Road;;;;", "note:lowercase is the same name"]
+      admitted.each.with_index do |line, position|
+        properties.insert(group_id: "household", position:, line:)
+      end
+      assert_equal admitted, properties.order(:position).map { it.fetch(:line) }
+
+      ["TEL;TYPE=home:+44 20 5555 0100", "EMAIL:boole@example.com", "item1.ADR:;;1 Long Road;;;;"].each do |line|
+        assert_raises(Sequel::ConstraintViolation) do
+          properties.insert(group_id: "household", position: admitted.size, line:)
+        end
+      end
+    end
+  end
+
   # The fourth action value exists for the fan-out a group edit will
   # write; nothing writes it yet, and this pins that the log can hold
   # one when it does.