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
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.