Drop the contact {} wrapper from contact files
One file is one contact, so the wrapper was ceremony in a format whose
point is hand-editable plaintext. Duplicate name nodes now raise instead
of silently preferring the first, taking over the structural check the
wrapper used to provide.

Assisted-by: GLM-5.3 via pi
change zvrpvvpsuutzpzouylkzyrwzuonxquwn
commit 86725b81396d2cc5618b16e9391f8e59c48d87ec
author Alpha Chen <alpha@kejadlen.dev>
date
parent lzvqwoqr
diff --git a/README.md b/README.md
index fed7ef5..262b469 100644
--- a/README.md
+++ b/README.md
@@ -22,11 +22,9 @@ Read-only and serving real data. Contacts live as KDL files under
 file, the filename doubling as the contact ID and the vCard UID:
 
 ```kdl
-contact {
-    name "John Smith"
-    phone "+1-555-1234" type="mobile"
-    email "john@example.com"
-}
+name "John Smith"
+phone "+1-555-1234" type="mobile"
+email "john@example.com"
 ```
 
 macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
diff --git a/docs/plans/2026-01-12-carddav-architecture.md b/docs/plans/2026-01-12-carddav-architecture.md
index 569b775..ee42ae0 100644
--- a/docs/plans/2026-01-12-carddav-architecture.md
+++ b/docs/plans/2026-01-12-carddav-architecture.md
@@ -89,12 +89,13 @@ data/
 
 ### Contact File Format (KDL)
 
+One bare KDL document per file — no `contact {}` wrapper, since the
+file itself is the contact:
+
 ```kdl
-contact {
-    name "John Smith"
-    phone "+1-555-1234" type="mobile"
-    email "john@example.com"
-}
+name "John Smith"
+phone "+1-555-1234" type="mobile"
+email "john@example.com"
 ```
 
 ### Contact IDs
diff --git a/docs/plans/2026-01-12-carddav-reference.md b/docs/plans/2026-01-12-carddav-reference.md
index a58f7b1..0c62918 100644
--- a/docs/plans/2026-01-12-carddav-reference.md
+++ b/docs/plans/2026-01-12-carddav-reference.md
@@ -236,19 +236,19 @@ END:VCARD
 
 ### KDL Input
 
+One bare KDL document per file — no `contact {}` wrapper:
+
 ```kdl
-contact {
-    name "John Smith"
-    phone "+1-555-1234" type="mobile"
-    phone "+1-555-5678" type="work"
-    email "john@example.com" type="home"
-    address type="home" {
-        street "123 Main St"
-        city "Springfield"
-        state "IL"
-        zip "62701"
-        country "USA"
-    }
+name "John Smith"
+phone "+1-555-1234" type="mobile"
+phone "+1-555-5678" type="work"
+email "john@example.com" type="home"
+address type="home" {
+    street "123 Main St"
+    city "Springfield"
+    state "IL"
+    zip "62701"
+    country "USA"
 }
 ```
 
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index c8882e3..1b6cfe7 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -7,8 +7,8 @@ require "pro_tacts/vcard"
 
 module ProTacts
   # A contact parsed from one KDL file under the contacts directory; the
-  # filename is the id, which maps to the vCard UID (see
-  # docs/plans/2026-01-12-carddav-architecture.md).
+  # file is a bare KDL document, and the filename is the id, which maps
+  # to the vCard UID (see docs/plans/2026-01-12-carddav-architecture.md).
   class Contact < Data.define(:id, :vcard)
     # Ids end up in paths and arrive from client-supplied hrefs, so a
     # filename outside this charset is skipped at load; everything
@@ -48,12 +48,7 @@ module ProTacts
         raise ArgumentError, "invalid contact id: #{id}"
       end
 
-      nodes = KDL.parse(path.read).nodes
-      unless nodes.length == 1 && nodes.first.name == "contact"
-        raise ArgumentError, "expected exactly one contact node"
-      end
-
-      new(id:, vcard: VCard.render(nodes.first, uid: id))
+      new(id:, vcard: VCard.render(KDL.parse(path.read), uid: id))
     end
   end
 end
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 6636f71..4b6ce0f 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -2,7 +2,8 @@
 require "kdl"
 
 module ProTacts
-  # Translates a parsed `contact` KDL node into a vCard 3.0 (RFC 2426).
+  # Translates a parsed contact file — a bare KDL document — into a
+  # vCard 3.0 (RFC 2426).
   #
   # The UID is passed separately because it lives in the filename rather
   # than the file (see docs/plans/2026-01-12-carddav-architecture.md).
@@ -34,23 +35,24 @@ module ProTacts
 
     module_function
 
-    def render(contact, uid:)
-      validate_children(contact, CONTACT_FIELDS, "contact")
-      validate_properties(contact)
+    def render(document, uid:)
+      nodes = document.nodes
+      validate_children(nodes, CONTACT_FIELDS, "contact")
 
-      name = contact.children.find { it.name == "name" }
-      raise ArgumentError, "contact requires a name" unless name
+      names = nodes.select { it.name == "name" }
+      raise ArgumentError, "contact requires a name" if names.empty?
+      raise ArgumentError, "contact takes a single name" if names.length > 1
 
-      n, fn = name_fields(name)
+      n, fn = name_fields(names.first)
 
       lines = [
         "BEGIN:VCARD",
         "VERSION:3.0",
         "N:#{components(n)}",
         "FN:#{escape(fn)}",
-        *typed_property_lines(contact, "phone", "TEL"),
-        *typed_property_lines(contact, "email", "EMAIL"),
-        *address_lines(contact),
+        *typed_property_lines(nodes, "phone", "TEL"),
+        *typed_property_lines(nodes, "email", "EMAIL"),
+        *address_lines(nodes),
         "UID:#{escape(uid)}",
         "END:VCARD",
         "", # trailing newline
@@ -98,8 +100,8 @@ module ProTacts
       end
     end
 
-    def typed_property_lines(contact, kdl_name, vcard_name)
-      contact.children.select { it.name == kdl_name }.map { |node|
+    def typed_property_lines(nodes, kdl_name, vcard_name)
+      nodes.select { it.name == kdl_name }.map { |node|
         validate_properties(node)
         type = node.properties["type"]&.value
         prefix = type ? "#{vcard_name};TYPE=#{type}" : vcard_name
@@ -110,8 +112,8 @@ module ProTacts
     # ADR's seven components in order: pobox, extended address, street,
     # locality, region, postal code, country. The first two have no KDL
     # counterpart and stay empty.
-    def address_lines(contact)
-      contact.children.select { it.name == "address" }.map { |node|
+    def address_lines(nodes)
+      nodes.select { it.name == "address" }.map { |node|
         validate_children(node, ADDRESS_PARTS, "address")
         validate_properties(node)
         parts = node.children
@@ -165,8 +167,8 @@ module ProTacts
       argument.value.to_s
     end
 
-    def validate_children(node, known, context)
-      unknown = node.children.reject { known.include?(it.name) }
+    def validate_children(nodes, known, context)
+      unknown = nodes.reject { known.include?(it.name) }
       return if unknown.empty?
 
       raise ArgumentError, "unknown key in #{context}: #{unknown.first.name}"
diff --git a/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
index e1a0409..c040be4 100644
--- a/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
+++ b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
@@ -1,3 +1 @@
-contact {
-    name "Test Contact"
-}
+name "Test Contact"
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index c8392fd..b30b72e 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -14,8 +14,8 @@ class ContactTest < Minitest::Test
 
   def test_all_parses_every_contact
     with_contacts({
-      "znorth.kdl" => "contact { name \"Zed\" }",
-      "aiden.kdl" => "contact { name \"Aiden\" }",
+      "znorth.kdl" => "name \"Zed\"",
+      "aiden.kdl" => "name \"Aiden\"",
     }) do |contacts|
       ids = contacts.map { it.id }
 
@@ -25,7 +25,7 @@ class ContactTest < Minitest::Test
   end
 
   def test_the_uid_comes_from_the_filename
-    with_contacts({"kqmtnwpxlrvszoyp.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+    with_contacts({"kqmtnwpxlrvszoyp.kdl" => "name \"Aiden\""}) do |contacts|
       assert_includes contacts.first.vcard, "UID:kqmtnwpxlrvszoyp"
     end
   end
@@ -58,7 +58,7 @@ class ContactTest < Minitest::Test
   def test_dotfiles_are_ignored
     with_contacts({
       ".DS_Store" => "junk",
-      "aiden.kdl" => "contact { name \"Aiden\" }",
+      "aiden.kdl" => "name \"Aiden\"",
     }) do |contacts|
       assert_equal %w[aiden], contacts.map { it.id }
     end
@@ -66,8 +66,8 @@ class ContactTest < Minitest::Test
 
   def test_files_whose_id_cannot_be_fetched_are_skipped
     with_contacts({
-      "John Smith.kdl" => "contact { name \"John\" }",
-      "aiden.kdl" => "contact { name \"Aiden\" }",
+      "John Smith.kdl" => "name \"John\"",
+      "aiden.kdl" => "name \"Aiden\"",
     }) do |contacts|
       assert_equal %w[aiden], contacts.map { it.id }
     end
@@ -76,26 +76,26 @@ class ContactTest < Minitest::Test
   def test_unparseable_files_are_skipped
     with_contacts({
       "broken.kdl" => "contact {",
-      "aiden.kdl" => "contact { name \"Aiden\" }",
+      "aiden.kdl" => "name \"Aiden\"",
     }) do |contacts|
       assert_equal %w[aiden], contacts.map { it.id }
     end
   end
 
-  def test_files_without_exactly_one_contact_node_are_skipped
+  def test_files_whose_keys_are_not_contact_fields_are_skipped
     with_contacts({
+      "person.kdl" => "person { name \"A\" }",
       "empty.kdl" => "",
-      "two.kdl" => "contact { name \"A\" }\ncontact { name \"B\" }",
-      "other.kdl" => "person { name \"A\" }",
+      "aiden.kdl" => "name \"Aiden\"",
     }) do |contacts|
-      assert_empty contacts
+      assert_equal %w[aiden], contacts.map { it.id }
     end
   end
 
   def test_files_whose_card_cannot_render_are_skipped
     with_contacts({
-      "nameless.kdl" => "contact { phone \"+1-555-1234\" }",
-      "aiden.kdl" => "contact { name \"Aiden\" }",
+      "nameless.kdl" => "phone \"+1-555-1234\"",
+      "aiden.kdl" => "name \"Aiden\"",
     }) do |contacts|
       assert_equal %w[aiden], contacts.map { it.id }
     end
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 756c239..ec90f56 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -10,25 +10,23 @@ class VCardTest < Minitest::Test
   include Hegel::Syntax::Methods
 
   def render(kdl, uid: "test-uid")
-    ProTacts::VCard.render(KDL.parse(kdl).nodes.first, uid:)
+    ProTacts::VCard.render(KDL.parse(kdl), uid:)
   end
 
   ## Unit tests
 
   def test_simple_contact
     vcard = render(<<~KDL)
-      contact {
-        name "John Smith"
-        phone "+1-555-1234" type="mobile"
-        email "john@example.com" type="home"
-        address type="home" {
-          street "123 Main St"
-          city "Springfield"
-          state "IL"
-          zip "62701"
-          country "USA"
-        }
-      }
+    name "John Smith"
+    phone "+1-555-1234" type="mobile"
+    email "john@example.com" type="home"
+    address type="home" {
+      street "123 Main St"
+      city "Springfield"
+      state "IL"
+      zip "62701"
+      country "USA"
+    }
     KDL
 
     assert_equal <<~VCARD.gsub("\n", "\r\n"), vcard
@@ -46,9 +44,7 @@ class VCardTest < Minitest::Test
 
   def test_single_token_name_gets_empty_given
     vcard = render(<<~KDL)
-      contact {
-        name "Cher"
-      }
+    name "Cher"
     KDL
 
     assert_includes vcard, "N:Cher;;;;"
@@ -56,12 +52,10 @@ class VCardTest < Minitest::Test
 
   def test_components_only_name
     vcard = render(<<~KDL)
-      contact {
-        name {
-          family "van Beethoven"
-          given "Ludwig"
-        }
-      }
+    name {
+      family "van Beethoven"
+      given "Ludwig"
+    }
     KDL
 
     assert_includes vcard, "N:van Beethoven;Ludwig;;;"
@@ -70,11 +64,9 @@ class VCardTest < Minitest::Test
 
   def test_one_component_leaves_the_rest_empty
     vcard = render(<<~KDL)
-      contact {
-        name {
-          family "Bach"
-        }
-      }
+    name {
+      family "Bach"
+    }
     KDL
 
     assert_includes vcard, "N:Bach;;;;"
@@ -83,15 +75,13 @@ class VCardTest < Minitest::Test
 
   def test_fn_joins_components_in_display_order
     vcard = render(<<~KDL)
-      contact {
-        name {
-          prefix "Dr."
-          given "John"
-          additional "Jacob"
-          family "Smith"
-          suffix "Jr."
-        }
-      }
+    name {
+      prefix "Dr."
+      given "John"
+      additional "Jacob"
+      family "Smith"
+      suffix "Jr."
+    }
     KDL
 
     assert_includes vcard, "FN:Dr. John Jacob Smith Jr."
@@ -99,9 +89,7 @@ class VCardTest < Minitest::Test
 
   def test_values_are_escaped
     vcard = render(<<~KDL)
-      contact {
-        name "semi;colon, comma back\\\\slash"
-      }
+    name "semi;colon, comma back\\\\slash"
     KDL
 
     assert_includes vcard, "FN:semi\\;colon\\, comma back\\\\slash"
@@ -109,9 +97,7 @@ class VCardTest < Minitest::Test
 
   def test_newlines_escape_as_literal_n
     vcard = render(<<~KDL)
-      contact {
-        name "two\\nlines"
-      }
+    name "two\\nlines"
     KDL
 
     assert_includes vcard, "FN:two\\nlines"
@@ -119,9 +105,7 @@ class VCardTest < Minitest::Test
 
   def test_long_lines_fold_and_unfold_intact
     vcard = render(<<~KDL)
-      contact {
-        name "#{"x" * 30}#{("é" * 60)}"
-      }
+    name "#{"x" * 30}#{("é" * 60)}"
     KDL
 
     physical = vcard.split("\r\n")
@@ -136,13 +120,11 @@ class VCardTest < Minitest::Test
 
   def test_properties_without_type
     vcard = render(<<~KDL)
-      contact {
-        name "John"
-        phone "+1-555-1234"
-        address {
-          street "123 Main St"
-        }
-      }
+    name "John"
+    phone "+1-555-1234"
+    address {
+      street "123 Main St"
+    }
     KDL
 
     assert_includes vcard, "TEL:+1-555-1234"
@@ -151,13 +133,11 @@ class VCardTest < Minitest::Test
 
   def test_order_is_preserved
     vcard = render(<<~KDL)
-      contact {
-        name "John"
-        phone "+1-555-1"
-        phone "+1-555-2"
-        email "a@example.com"
-        phone "+1-555-3"
-      }
+    name "John"
+    phone "+1-555-1"
+    phone "+1-555-2"
+    email "a@example.com"
+    phone "+1-555-3"
     KDL
 
     lines = vcard.split("\r\n")
@@ -168,23 +148,30 @@ class VCardTest < Minitest::Test
   def test_missing_name_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          phone "+1-555-1234"
-        }
+      phone "+1-555-1234"
       KDL
     end
 
     assert_equal "contact requires a name", error.message
   end
 
+  def test_duplicate_names_raise
+    error = assert_raises(ArgumentError) do
+      render(<<~KDL)
+        name "One"
+        name "Two"
+      KDL
+    end
+
+    assert_equal "contact takes a single name", error.message
+  end
+
   def test_name_with_both_display_and_components_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name "Ludwig van Beethoven" {
-            family "van Beethoven"
-          }
-        }
+      name "Ludwig van Beethoven" {
+        family "van Beethoven"
+      }
       KDL
     end
 
@@ -194,9 +181,7 @@ class VCardTest < Minitest::Test
   def test_name_with_neither_display_nor_components_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name
-        }
+      name
       KDL
     end
 
@@ -206,11 +191,9 @@ class VCardTest < Minitest::Test
   def test_all_empty_components_raise
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name {
-            family ""
-          }
-        }
+      name {
+        family ""
+      }
       KDL
     end
 
@@ -220,10 +203,8 @@ class VCardTest < Minitest::Test
   def test_property_without_value_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name "John"
-          phone type="mobile"
-        }
+      name "John"
+      phone type="mobile"
       KDL
     end
 
@@ -233,10 +214,8 @@ class VCardTest < Minitest::Test
   def test_unknown_contact_key_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name "John"
-          emial "john@example.com"
-        }
+      name "John"
+      emial "john@example.com"
       KDL
     end
 
@@ -246,12 +225,10 @@ class VCardTest < Minitest::Test
   def test_unknown_name_component_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name {
-            family "Smith"
-            middle "Q"
-          }
-        }
+      name {
+        family "Smith"
+        middle "Q"
+      }
       KDL
     end
 
@@ -261,13 +238,11 @@ class VCardTest < Minitest::Test
   def test_unknown_address_key_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name "John"
-          address {
-            street "123 Main St"
-            province "IL"
-          }
-        }
+      name "John"
+      address {
+        street "123 Main St"
+        province "IL"
+      }
       KDL
     end
 
@@ -277,10 +252,8 @@ class VCardTest < Minitest::Test
   def test_unknown_property_raises
     error = assert_raises(ArgumentError) do
       render(<<~KDL)
-        contact {
-          name "John"
-          phone "+1-555-1234" tpye="mobile"
-        }
+      name "John"
+      phone "+1-555-1234" tpye="mobile"
       KDL
     end
 
@@ -407,7 +380,7 @@ class VCardTest < Minitest::Test
     name_children = {family:, given:, additional:, prefix:, suffix:}
       .filter_map { |part, value| "#{part} #{kdl_string(value)}" unless value.nil? }
 
-    contact = +"contact {\n"
+    contact = +""
     if name_children.empty?
       contact << "  name #{kdl_string(display)}\n"
     else
@@ -426,14 +399,14 @@ class VCardTest < Minitest::Test
       inner = parts.map { |part, value| "    #{part} #{kdl_string(value)}\n" }.join
       contact << "  address#{suffix} {\n#{inner}  }\n"
     end
-    contact << "}\n"
+    contact
   end
 
   def test_escaped_fn_survives_round_trip
     Hegel.test do |tc|
       display = tc.draw(text(min_size: 1, max_size: 200))
       vcard = ProTacts::VCard.render(
-        KDL.parse(kdl_contact(display:)).nodes.first,
+        KDL.parse(kdl_contact(display:)),
         uid: "uid"
       )
 
@@ -447,7 +420,7 @@ class VCardTest < Minitest::Test
       display = tc.draw(text(min_size: 1, max_size: 300))
       uid = tc.draw(text(min_size: 1, max_size: 300))
       vcard = ProTacts::VCard.render(
-        KDL.parse(kdl_contact(display:)).nodes.first,
+        KDL.parse(kdl_contact(display:)),
         uid:
       )
 
@@ -494,7 +467,7 @@ class VCardTest < Minitest::Test
           [%w[street city state zip country].zip([street, city, state, zip, country]).to_h, addr_type]
         }
       )
-      vcard = ProTacts::VCard.render(KDL.parse(kdl).nodes.first, uid:)
+      vcard = ProTacts::VCard.render(KDL.parse(kdl), uid:)
       parsed = parse_vcard(vcard)
 
       raise "FN mismatch" unless parsed.fetch(:fn) == expected_fn(display, components)
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 70c6c61..ec4126a 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -152,8 +152,8 @@ class WebTest < Minitest::Test
 
   def test_listing_and_multiget_serve_every_contact_on_disk
     with_contacts({
-      "aiden.kdl" => "contact { name \"Aiden\" }",
-      "znorth.kdl" => "contact { name \"Zed\" }",
+      "aiden.kdl" => "name \"Aiden\"",
+      "znorth.kdl" => "name \"Zed\"",
     }) do
       request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
 
@@ -172,7 +172,7 @@ class WebTest < Minitest::Test
   end
 
   def test_multiget_reports_unknown_hrefs_as_404
-    with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do
       request "/dav/addressbook/", method: "REPORT", input: multiget("aiden", "nope")
 
       assert_equal 207, last_response.status
@@ -183,7 +183,7 @@ class WebTest < Minitest::Test
   end
 
   def test_multiget_escapes_vcard_content_for_xml
-    with_contacts({"aiden.kdl" => "contact { name \"A & B <Team>\" }"}) do
+    with_contacts({"aiden.kdl" => "name \"A & B <Team>\""}) do
       request "/dav/addressbook/", method: "REPORT", input: multiget("aiden")
 
       assert_equal 207, last_response.status
@@ -192,7 +192,7 @@ class WebTest < Minitest::Test
   end
 
   def test_sync_collection_returns_etags_only
-    with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do
       request "/dav/addressbook/", method: "REPORT", input: <<~XML
         <?xml version="1.0" encoding="UTF-8"?>
         <A:sync-collection xmlns:A="DAV:">