Contact: the one model of a contact
The admin UI re-parsed the served card through ContactFields; it now
reads structured accessors that parse lazily, so the serving paths pay
nothing. docs/plans/2026-09-01-contact-is-the-model.md has the shape.

Assisted-by: GLM-5.3 via pi
change okrxkszzyvysmwwsyoxtzkrqtkytrvvv
commit 5ee5fcfed96da140c7ae8a7da90fefd2132bbbfc
author Alpha Chen <alpha@kejadlen.dev>
date
parent kkkupptn
diff --git a/AGENTS.md b/AGENTS.md
index 3609ad6..465e979 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,8 @@ is already `pro-tacts` — don't pass `--backlog` or verify it.
 lib/pro_tacts/
 ├── web.rb          # The Roda app: every route and response body
 ├── store.rb        # Sequel over SQLite: cards, change log, derived index
-├── contact.rb      # id and vCard, with the etag derived from the card
+├── contact.rb      # the one model of a contact: id, vCard, etag,
+│                   # and the structured accessors over the card
 ├── vcard.rb        # vCard 3.0 escaping and folding, and what a card is
 ├── vcard/parser.rb # One card's bytes into properties
 ├── config.rb       # Every environment read in the app
@@ -93,7 +94,7 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   rescuing it locally "to be safe" makes it neither. Rescue only what's an
   ordinary, anticipated case with a real fallback to show (e.g. a birthday
   whose stored value is well-shaped but calendar-nonsense — see
-  `ContactFields#format_birthday`), never "this might fail, better catch
+  `Admin::Format.birthday`), never "this might fail, better catch
   it."
 - `config.ru` must stay ASCII-only; a test enforces it, because boot crashes
   under a C locale otherwise. Watch for em dashes in comments.
diff --git a/lib/pro_tacts/admin/contact_fields.rb b/lib/pro_tacts/admin/contact_fields.rb
deleted file mode 100644
index cbdba5b..0000000
--- a/lib/pro_tacts/admin/contact_fields.rb
+++ /dev/null
@@ -1,112 +0,0 @@
-require "date"
-
-require "pro_tacts/admin/format"
-require "pro_tacts/birthday"
-require "pro_tacts/vcard"
-require "pro_tacts/vcard/parser"
-
-module ProTacts
-  module Admin
-    # The read-only fields an admin screen shows for a contact, parsed
-    # fresh from its stored vCard on every read. Deliberately shallow,
-    # like VCard::Parser itself: a card can carry properties this has no
-    # field for, and CardDAV still serves every one of them — this is
-    # only what one screen knows how to display.
-    class ContactFields
-      Phone = Data.define(:value, :type)
-      Email = Data.define(:value, :type)
-      Address = Data.define(:lines, :type)
-
-      attr_reader :name, :initials, :phones, :emails, :addresses, :birthday, :notes
-
-      # Lets VCard::ParseError raise rather than showing an empty card:
-      # a stored card that fails to parse here is not the ordinary case
-      # #format_birthday's rescue handles (a well-shaped but nonsense
-      # value that round-trips on purpose) — every PUT already checks
-      # VCard.card? before the store accepts a card, so one reaching
-      # this unparseable is a bug or a corrupt row, and Sentry (already
-      # wired into every request, see ProTacts::Web) is where that
-      # should surface, not a silently blank screen.
-      #: (Contact contact) -> ContactFields
-      def self.from(contact)
-        properties = VCard::Parser.parse(contact.vcard)
-        new(properties)
-      end
-
-      #: (Array[VCard::Property] properties) -> void
-      def initialize(properties)
-        by_name = properties.group_by { it.name.upcase }
-        @name = by_name.fetch("FN", []).first&.value
-        @initials = initials_from(by_name.fetch("N", []).first) || (@name && Format.initials(@name))
-        @phones = by_name.fetch("TEL", []).map { |p| Phone.new(value: VCard.unescape(p.value), type: type_of(p)) }
-        @emails = by_name.fetch("EMAIL", []).map { |p| Email.new(value: VCard.unescape(p.value), type: type_of(p)) }
-        @addresses = by_name.fetch("ADR", []).map { |p| address_from(p) }
-        @birthday = format_birthday(by_name.fetch("BDAY", []).first)
-        note = by_name.fetch("NOTE", []).first&.value
-        @notes = note && VCard.unescape(note)
-      end
-
-      private
-
-      # RFC 2426 section 3.1.2: N is Family;Given;Additional;Prefixes;
-      # Suffixes. A given and family name are structured data — reading
-      # them is the real answer to "what are this contact's initials,"
-      # rather than guessing at where a first/last split falls in the
-      # free-text FN. A card with no N at all (an organization's own
-      # address-book entry, say, with a name but no person to split in
-      # two) falls back to Format.initials' single first letter.
-      #: (VCard::Property? property) -> String?
-      def initials_from(property)
-        return nil if property.nil?
-
-        family, given = VCard.split_components(property.value)
-        letters = [given, family].filter_map { |part| part[0] unless part.to_s.empty? }
-        letters.empty? ? nil : letters.join.upcase
-      end
-
-      # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
-      # comma-list (TYPE=work,voice) — either arrives here as one
-      # parameter per value, so the first is the one this shows.
-      #: (VCard::Property property) -> String?
-      def type_of(property)
-        property.parameters.find { |name, _| name.casecmp?("TYPE") }&.last&.downcase
-      end
-
-      # ADR's components, RFC 2426 section 3.2.1: post office box,
-      # extended address, street, locality, region, postal code,
-      # country. Displayed as two lines — street, then everything after
-      # it — the same shape the design's own address rows settled on.
-      #: (VCard::Property property) -> Address
-      def address_from(property)
-        _po_box, extended, street, city, region, postal_code, country = VCard.split_components(property.value)
-        lines = [
-          [extended, street].reject { |s| s.nil? || s.empty? }.join(" "),
-          [city, region, postal_code].reject { |s| s.nil? || s.empty? }.join(", "),
-          country,
-        ].reject { |s| s.nil? || s.empty? }
-        Address.new(lines:, type: type_of(property))
-      end
-
-      # Reuses ProTacts::Birthday, which knows the two BDAY spellings a
-      # served card actually carries (see lib/pro_tacts/birthday.rb): a
-      # plain full date, and Apple's 1604-sentinel no-year form. A BDAY
-      # in any other shape is one the app's model doesn't recompose —
-      # kept in the card verbatim on write — so it's shown as stored.
-      #: (VCard::Property? property) -> String?
-      def format_birthday(property)
-        return nil if property.nil?
-
-        birthday = Birthday.from_property(property)
-        return property.value if birthday.nil?
-
-        if birthday.year
-          Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
-        else
-          Date.new(2000, birthday.month, birthday.day).strftime("%B %-d")
-        end
-      rescue ArgumentError
-        property.value
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 28b587a..d3621cd 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -1,6 +1,5 @@
 require "phlex"
 
-require "pro_tacts/admin/contact_fields"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
 
@@ -13,13 +12,13 @@ module ProTacts
     class ContactsIndex < Phlex::HTML
       RECENT_LIMIT = 10
 
-      Row = Data.define(:contact, :fields, :updated_at)
+      Row = Data.define(:contact, :updated_at)
       private_constant :Row
 
       #: (recent: Array[Store::RecentContact], query: String?) -> void
       def initialize(recent:, query:)
         @query = query.to_s.strip
-        rows = recent.map { Row.new(contact: it.contact, fields: ContactFields.from(it.contact), updated_at: it.updated_at) }
+        rows = recent.map { Row.new(contact: it.contact, updated_at: it.updated_at) }
         @rows = @query.empty? ? rows.first(RECENT_LIMIT) : rows.select { matches?(it, @query) }
       end
 
@@ -43,9 +42,9 @@ module ProTacts
       #: (Row row, String query) -> bool
       def matches?(row, query)
         q = query.downcase
-        return true if row.fields.name&.downcase&.include?(q)
-        return true if row.fields.phones.any? { it.value.downcase.include?(q) }
-        return true if row.fields.emails.any? { it.value.downcase.include?(q) }
+        return true if row.contact.name&.downcase&.include?(q)
+        return true if row.contact.phones.any? { it.value.downcase.include?(q) }
+        return true if row.contact.emails.any? { it.value.downcase.include?(q) }
 
         false
       end
@@ -54,9 +53,9 @@ module ProTacts
       def render_row(row)
         li do
           a(href: "/contacts/#{row.contact.id}") do
-            span(class: "avatar") { row.fields.initials || Format.initials(row.contact.id) }
+            span(class: "avatar") { Format.initials(row.contact) }
             div(style: "flex: 1; min-width: 0;") do
-              div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
+              div(style: "font-weight: 550;") { row.contact.name || row.contact.id }
             end
             span(class: "type-label") { Format.time_ago(row.updated_at) }
           end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index d91d196..64dc1ba 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -1,6 +1,5 @@
 require "phlex"
 
-require "pro_tacts/admin/contact_fields"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
 
@@ -20,17 +19,17 @@ module ProTacts
       #: (Contact contact) -> void
       def initialize(contact:)
         @contact = contact
-        @fields = ContactFields.from(contact)
+        @birthday = Format.birthday(contact)
       end
 
       def view_template
-        render Layout.new(title: @fields.name || @contact.id) do
+        render Layout.new(title: @contact.name || @contact.id) do
           a(href: "/", class: "type-label") { "‹ contacts" }
           div(class: "card") do
             div(class: "card-body") do
               div(class: "detail-header") do
-                span(class: "avatar", data_size: "lg") { @fields.initials || Format.initials(@contact.id) }
-                h1(class: "type-h2", style: "margin: 0;") { @fields.name || @contact.id }
+                span(class: "avatar", data_size: "lg") { Format.initials(@contact) }
+                h1(class: "type-h2", style: "margin: 0;") { @contact.name || @contact.id }
               end
               # Only rendered when there's something to show: an empty
               # <dl> would still take up the gap card-body puts between
@@ -46,16 +45,16 @@ module ProTacts
 
       #: () -> bool
       def has_data?
-        @fields.phones.any? || @fields.emails.any? || @fields.addresses.any? ||
-          !@fields.birthday.nil? || !@fields.notes.nil?
+        @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
+          !@birthday.nil? || !@contact.notes.nil?
       end
 
       def rows
-        @fields.phones.each { |phone| row(phone.type, phone.value) }
-        @fields.emails.each { |email| row(email.type, email.value) }
-        @fields.addresses.each { |address| row(address.type || "address", address.lines) }
-        row("birthday", @fields.birthday) if @fields.birthday
-        row("notes", @fields.notes) if @fields.notes
+        @contact.phones.each { |phone| row(phone.type, phone.value) }
+        @contact.emails.each { |email| row(email.type, 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
       end
 
       #: (String? type, String | Array[String] value) -> void
@@ -64,6 +63,18 @@ module ProTacts
         dd(class: "type-body-sm") { render_value(value) }
       end
 
+      # ADR's components as two display lines — street, then everything
+      # after it — the same shape the design's own address rows settled
+      # on. Presentation: the components themselves are Contact's.
+      #: (Contact::Address address) -> Array[String]
+      def address_lines(address)
+        [
+          [address.extended, address.street].reject { |s| s.nil? || s.empty? }.join(" "),
+          [address.locality, address.region, address.postal_code].reject { |s| s.nil? || s.empty? }.join(", "),
+          address.country,
+        ].reject { |s| s.nil? || s.empty? }
+      end
+
       #: (String | Array[String] value) -> void
       def render_value(value)
         if value.is_a?(Array)
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index e558fb8..6dc45bf 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -1,16 +1,53 @@
+require "date"
 require "time"
 
 module ProTacts
   module Admin
-    # Small display helpers shared by the admin views.
+    # Small display helpers shared by the admin views. Presentation, not
+    # structure: these read a Contact but decide only how it looks.
     module Format
-      # The first letter of a name with nothing more structured to go
-      # on — an id, or a display name with no N property behind it
-      # (see ContactFields#initials_from). One letter rather than a
-      # guess at where a first/last split would fall in free text.
-      #: (String name) -> String
-      def self.initials(name)
-        name.to_s.strip[0].to_s.upcase
+      # A contact's initials. N's given and family names (RFC 2426
+      # section 3.1.2) are structured data and the real answer to "what
+      # are this contact's initials" — falling back, when the card
+      # carries no N (an organization's own entry, say, with a name but
+      # no person to split in two), to one first letter of FN rather
+      # than a guess at where a first/last split would fall in free
+      # text, and of the id when the card carries no name at all.
+      #: (Contact contact) -> String
+      def self.initials(contact)
+        family, given = contact.name_components || []
+        letters = [given, family].filter_map { it[0] unless it.to_s.empty? }
+        return letters.join.upcase unless letters.empty?
+
+        (contact.name || contact.id).to_s.strip[0].to_s.upcase
+      end
+
+      # The birthday a card carries, for display: the model's date when
+      # the model holds one, the raw property when it does not — a BDAY
+      # in a spelling the model doesn't recompose is kept in the card
+      # verbatim on write, so it's shown as stored.
+      #: (Contact contact) -> String?
+      def self.birthday(contact)
+        birthday = contact.birthday
+        return raw_birthday(contact) if birthday.nil?
+
+        if birthday.year
+          Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
+        else
+          Date.new(2000, birthday.month, birthday.day).strftime("%B %-d")
+        end
+      rescue ArgumentError
+        # A birthday whose stored value is well-shaped but
+        # calendar-nonsense: the model checks component ranges, not the
+        # calendar, so February 30 parses and only Date.new refuses it.
+        # The ordinary anticipated case, shown as stored rather than
+        # raised.
+        raw_birthday(contact)
+      end
+
+      #: (Contact contact) -> String?
+      def self.raw_birthday(contact)
+        contact.properties.find { it.name.casecmp?("BDAY") }&.value
       end
 
       # A card's updated_at, UTC ISO 8601 to the millisecond (see
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index d380aa2..5ab9674 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,8 +1,14 @@
 require "digest"
 
+require "pro_tacts/birthday"
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
 module ProTacts
-  # One contact: its id, the vCard bytes served for it, and the etag over
-  # those bytes. The id is the vCard's UID and the name in its href (see
+  # One contact: its id, the vCard bytes served for it, the etag over
+  # those bytes, and the structured read of them — the one model of a
+  # contact (docs/plans/2026-09-01-contact-is-the-model.md). The id is
+  # the vCard's UID and the name in its href (see
   # docs/plans/2026-01-12-carddav-architecture.md).
   #
   # The etag hashes the card that goes out, so it changes exactly when
@@ -13,29 +19,164 @@ module ProTacts
   # in the entity-tag's quoted form (RFC 7232 section 2.3), which is what
   # both the ETag header and getetag properties carry.
   #
-  # Stored and served are the same bytes today. Once a group contributes
-  # properties at render time they will not be, and this becomes the hash
-  # of the composed card — still derived, from more inputs.
+  # The structured accessors are that idea applied to the card's
+  # contents: everything derives from the bytes, lazily, on the first
+  # structured read. The CardDAV paths never take one — they answer in
+  # bytes — and ctag and the listing reads build a contact per row
+  # without reading structure, so deferring the parse keeps every
+  # serving path as cheap as it was without the accessors. A regular
+  # class rather than a Data one because the memoized parse needs an
+  # ivar and Data freezes its instances.
   #
-  # The signature lives in sig/pro_tacts/contact.rbs: a Data class has
-  # no constant super class for the inline syntax to read.
-  # @rbs skip
-  class Contact < Data.define(:id, :vcard, :etag)
+  # The accessors are deliberately shallow, like the parser: a card can
+  # carry properties no accessor knows, and CardDAV still serves every
+  # one. #properties is the read for those.
+  class Contact
+    # @rbs @id: String
+    # @rbs @vcard: String
+    # @rbs @etag: String
+    # @rbs @properties: Array[VCard::Property]
+
     # Ids end up in paths and arrive from client-supplied hrefs, so an id
     # outside this charset cannot be served.
-    ID_FORMAT = /\A[\w-]+\z/
+    ID_FORMAT = /\A[\w-]+\z/ #: Regexp
+
+    # The structured shapes the accessors return: text value and type
+    # for TEL (RFC 2426 section 3.3.1) and EMAIL (section 3.3.2), and
+    # ADR's seven components (section 3.2.1: post office box, extended
+    # address, street, locality, region, postal code, country — nil
+    # where the card's value stopped short of the position). Data
+    # classes, whose members the inline syntax cannot read; the
+    # signatures live in sig/pro_tacts/contact.rbs.
+    # @rbs skip
+    Phone = Data.define(:value, :type)
+    # @rbs skip
+    Email = Data.define(:value, :type)
+    # @rbs skip
+    Address = Data.define(
+      :po_box,
+      :extended,
+      :street,
+      :locality,
+      :region,
+      :postal_code,
+      :country,
+      :type,
+    )
 
     # A contact from its id and its card. The only way to make one: an
     # etag that came from anywhere but the card in hand is an etag that
     # can be wrong.
+    #: (id: String, vcard: String) -> Contact
     def self.for(id:, vcard:)
       raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
 
-      new(id:, vcard:, etag: etag_for(vcard))
+      new(id, vcard, etag_for(vcard))
     end
 
+    #: (String vcard) -> String
     def self.etag_for(vcard)
       %("#{Digest::SHA256.hexdigest(vcard)}")
     end
+
+    #: (String id, String vcard, String etag) -> void
+    def initialize(id, vcard, etag)
+      @id = id
+      @vcard = vcard
+      @etag = etag
+    end
+
+    attr_reader :id
+
+    attr_reader :vcard
+
+    attr_reader :etag
+
+    # The card's properties, parsed once and memoized — the substrate
+    # the typed accessors sit on, and the read for what none of them
+    # models. Lets VCard::ParseError raise rather than returning an
+    # empty card: PUT checks VCard.card? before the store accepts a
+    # card, so one reaching this unparseable is a bug or a corrupt row,
+    # and Sentry (already wired into every request, see ProTacts::Web)
+    # is where that should surface, not a silently blank screen.
+    #: () -> Array[VCard::Property]
+    def properties
+      return @properties if defined?(@properties)
+
+      @properties = VCard::Parser.parse(@vcard)
+    end
+
+    # FN's value (RFC 2426 section 3.1.1), in text form.
+    #: () -> String?
+    def name
+      property = properties.find { it.name.casecmp?("FN") }
+      property && VCard.unescape(property.value)
+    end
+
+    # N's components (RFC 2426 section 3.1.2: family; given; additional;
+    # prefixes; suffixes), split and unescaped, or nil with no N at all.
+    # Structured data — reading initials off it is the real answer to
+    # where a first/last split falls, rather than guessing in the
+    # free-text FN.
+    #: () -> Array[String]?
+    def name_components
+      property = properties.find { it.name.casecmp?("N") }
+      property && VCard.split_components(property.value)
+    end
+
+    #: () -> Array[Phone]
+    def phones
+      of_name("TEL").map { Phone.new(value: VCard.unescape(it.value), type: type_of(it)) }
+    end
+
+    #: () -> Array[Email]
+    def emails
+      of_name("EMAIL").map { Email.new(value: VCard.unescape(it.value), type: type_of(it)) }
+    end
+
+    #: () -> Array[Address]
+    def addresses
+      of_name("ADR").map { address_of(it) }
+    end
+
+    # The birthday the served card carries — the model the store
+    # composed into it (see Birthday), parsed back out because the two
+    # forms Birthday serves are exactly the two from_property accepts.
+    # Nil when the card carries no BDAY the model recomposes; an
+    # unmodeled spelling stays in the card and is #properties' to show.
+    #: () -> Birthday?
+    def birthday
+      property = properties.find { it.name.casecmp?("BDAY") }
+      property && Birthday.from_property(property)
+    end
+
+    # The card's NOTE (RFC 2426 section 3.6.2), in text form.
+    #: () -> String?
+    def notes
+      property = properties.find { it.name.casecmp?("NOTE") }
+      property && VCard.unescape(property.value)
+    end
+
+    private
+
+    #: (String name) -> Array[VCard::Property]
+    def of_name(name)
+      properties.select { it.name.casecmp?(name) }
+    end
+
+    #: (VCard::Property property) -> Address
+    def address_of(property)
+      po_box, extended, street, locality, region, postal_code, country =
+        VCard.split_components(property.value)
+      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, type: type_of(property))
+    end
+
+    # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
+    # comma-list (TYPE=work,voice) — either arrives here as one
+    # parameter per value, so the first is the one this carries.
+    #: (VCard::Property property) -> String?
+    def type_of(property)
+      property.parameters.find { |name, _| name.casecmp?("TYPE") }&.last&.downcase
+    end
   end
 end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 6f80dd3..1d85267 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,19 +1,44 @@
-# `class Contact < Data.define(...)` has no constant for a super class,
-# which is what inline RBS needs to read one (RBS docs/data_and_struct.md
-# covers the pattern). The class is marked `@rbs skip` in contact.rb and
-# its signature written out here instead.
+# Contact::Phone, ::Email, 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
+# signature inline.
 module ProTacts
   class Contact
-    ID_FORMAT: Regexp
+    class Phone
+      attr_reader value: String
+      attr_reader type: String?
 
-    attr_reader id: String
-    attr_reader vcard: String
-    attr_reader etag: String
+      def self.new: (value: String, type: String?) -> instance
+    end
 
-    def self.new: (id: String, vcard: String, etag: String) -> instance
+    class Email
+      attr_reader value: String
+      attr_reader type: String?
 
-    def self.for: (id: String, vcard: String) -> Contact
+      def self.new: (value: String, type: String?) -> instance
+    end
 
-    def self.etag_for: (String vcard) -> String
+    class Address
+      attr_reader po_box: String?
+      attr_reader extended: String?
+      attr_reader street: String?
+      attr_reader locality: String?
+      attr_reader region: String?
+      attr_reader postal_code: String?
+      attr_reader country: String?
+      attr_reader type: String?
+
+      def self.new: (
+        po_box: String?,
+        extended: String?,
+        street: String?,
+        locality: String?,
+        region: String?,
+        postal_code: String?,
+        country: String?,
+        type: String?
+      ) -> instance
+    end
   end
 end
diff --git a/test/pro_tacts/admin/test_contact_fields.rb b/test/pro_tacts/admin/test_contact_fields.rb
deleted file mode 100644
index afd416a..0000000
--- a/test/pro_tacts/admin/test_contact_fields.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-require_relative "../../test_helper"
-
-require "pro_tacts/admin/contact_fields"
-require "pro_tacts/contact"
-
-class ContactFieldsTest < Minitest::Test
-  def fields(vcard)
-    ProTacts::Admin::ContactFields.from(ProTacts::Contact.for(id: "aiden", vcard:))
-  end
-
-  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
-    "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
-    "EMAIL;TYPE=home:ada@example.com\r\n" \
-    "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
-    "BDAY:1985-12-10\r\nNOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
-
-  def test_reads_the_name
-    assert_equal "Ada Lovelace", fields(CARD).name
-  end
-
-  # Initials come from the structured N property (given + family),
-  # not a guess at word boundaries in the free-text FN.
-  def test_initials_come_from_the_structured_name
-    assert_equal "AL", fields(CARD).initials
-  end
-
-  # A card with no N at all — an organization's own entry, say — falls
-  # back to Format.initials' single first letter of FN.
-  def test_initials_fall_back_to_the_display_name_with_no_n_property
-    no_n = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Riverside Elementary\r\nUID:office\r\nEND:VCARD\r\n"
-
-    assert_equal "R", fields(no_n).initials
-  end
-
-  # N with only one of given/family present (a mononym, or a card that
-  # only bothered with a family name) still gives one initial rather
-  # than raising on the missing half.
-  def test_initials_from_a_partial_n_property
-    family_only = CARD.sub("N:Lovelace;Ada;;;", "N:Lovelace;;;;")
-
-    assert_equal "L", fields(family_only).initials
-  end
-
-  def test_reads_every_phone_with_its_type
-    assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
-      fields(CARD).phones.map { [it.value, it.type] }
-  end
-
-  def test_reads_email
-    emails = fields(CARD).emails
-    assert_equal 1, emails.length
-    assert_equal "ada@example.com", emails.fetch(0).value
-    assert_equal "home", emails.fetch(0).type
-  end
-
-  def test_reads_address_as_two_lines
-    addresses = fields(CARD).addresses
-    assert_equal 1, addresses.length
-    address = addresses.fetch(0)
-    assert_equal ["12 Analytical Way", "London, England, NW1 1AA", "United Kingdom"], address.lines
-    assert_equal "home", address.type
-  end
-
-  def test_formats_the_birthday
-    assert_equal "December 10, 1985", fields(CARD).birthday
-  end
-
-  def test_unescapes_notes
-    assert_equal "Countess, mathematician.", fields(CARD).notes
-  end
-
-  def test_attributes_with_no_data_are_nil_or_empty
-    bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare\r\nUID:bare\r\nEND:VCARD\r\n"
-    fields = fields(bare)
-
-    assert_empty fields.phones
-    assert_empty fields.emails
-    assert_empty fields.addresses
-    assert_nil fields.birthday
-    assert_nil fields.notes
-  end
-
-  # Unlike Store#rebuild_index, which fails a card open because a
-  # write already guarantees every stored card parses (VCard.card? is
-  # checked before a PUT is accepted) — a card that reaches here and
-  # still won't parse is a bug or a corrupt row, not the ordinary case,
-  # and it should raise into Sentry rather than render a blank card.
-  def test_a_card_that_will_not_parse_raises
-    assert_raises(ProTacts::VCard::ParseError) { fields("not a vCard at all") }
-  end
-
-  # RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
-  # free to send one; the display only reads the date part of it.
-  def test_a_birthday_with_a_time_component_still_formats
-    with_time = CARD.sub("BDAY:1985-12-10", "BDAY:1985-12-10T00:00:00Z")
-
-    assert_equal "December 10, 1985", fields(with_time).birthday
-  end
-
-  # Apple's 1604-sentinel spelling for "no year on file" (see
-  # ProTacts::Birthday), the shape a served card actually carries.
-  def test_a_birthday_with_no_year_omits_one
-    no_year = CARD.sub("BDAY:1985-12-10", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10")
-
-    assert_equal "December 10", fields(no_year).birthday
-  end
-
-  def test_a_birthday_that_will_not_parse_is_shown_as_stored
-    unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")
-
-    assert_equal "not-a-date", fields(unparseable).birthday
-  end
-end
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index c8710b3..1df9088 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -1,16 +1,90 @@
 require_relative "../../test_helper"
 
 require "pro_tacts/admin/format"
+require "pro_tacts/contact"
 
 class FormatTest < Minitest::Test
-  def test_initials_is_the_first_letter_of_the_name
-    assert_equal "A", ProTacts::Admin::Format.initials("Ada Lovelace")
-    assert_equal "A", ProTacts::Admin::Format.initials("Ada")
-    assert_equal "R", ProTacts::Admin::Format.initials("Riverside Elementary Front Office")
+  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
+    "BDAY:1985-12-10\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  def contact(vcard = CARD, id: "aiden")
+    ProTacts::Contact.for(id:, vcard:)
+  end
+
+  # Initials come from the structured N property (given + family),
+  # not a guess at word boundaries in the free-text FN.
+  def test_initials_come_from_the_structured_name
+    assert_equal "AL", ProTacts::Admin::Format.initials(contact)
+  end
+
+  # A card with no N at all — an organization's own entry, say — falls
+  # back to one first letter of FN.
+  def test_initials_fall_back_to_the_display_name_with_no_n_property
+    no_n = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Riverside Elementary\r\nUID:office\r\nEND:VCARD\r\n"
+
+    assert_equal "R", ProTacts::Admin::Format.initials(contact(no_n))
+  end
+
+  def test_initials_fall_back_to_the_id_when_there_is_no_name_at_all
+    bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:zz9\r\nEND:VCARD\r\n"
+
+    assert_equal "Z", ProTacts::Admin::Format.initials(contact(bare, id: "znorth"))
   end
 
   def test_initials_of_a_blank_name_is_blank
-    assert_equal "", ProTacts::Admin::Format.initials("")
+    blank = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:\r\nUID:b\r\nEND:VCARD\r\n"
+
+    assert_equal "", ProTacts::Admin::Format.initials(contact(blank))
+  end
+
+  # N with only one of given/family present (a mononym, or a card that
+  # only bothered with a family name) still gives one initial rather
+  # than raising on the missing half.
+  def test_initials_from_a_partial_n_property
+    family_only = CARD.sub("N:Lovelace;Ada;;;", "N:Lovelace;;;;")
+
+    assert_equal "L", ProTacts::Admin::Format.initials(contact(family_only))
+  end
+
+  def test_formats_the_birthday
+    assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact)
+  end
+
+  # RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
+  # free to send one; the display only reads the date part of it.
+  def test_a_birthday_with_a_time_component_still_formats
+    with_time = CARD.sub("BDAY:1985-12-10", "BDAY:1985-12-10T00:00:00Z")
+
+    assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(with_time))
+  end
+
+  # Apple's 1604-sentinel spelling for "no year on file" (see
+  # ProTacts::Birthday), the shape a served card actually carries.
+  def test_a_birthday_with_no_year_omits_one
+    no_year = CARD.sub("BDAY:1985-12-10", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-12-10")
+
+    assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(no_year))
+  end
+
+  def test_a_birthday_that_will_not_parse_is_shown_as_stored
+    unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+
+    assert_equal "not-a-date", ProTacts::Admin::Format.birthday(contact(unparseable))
+  end
+
+  # Well-shaped but calendar-nonsense: the model checks component
+  # ranges, not the calendar, so February 30 parses and only Date.new
+  # refuses it. The anticipated case, shown as stored rather than raised.
+  def test_a_calendar_nonsense_birthday_is_shown_as_stored
+    nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:1985-02-30")
+
+    assert_equal "1985-02-30", ProTacts::Admin::Format.birthday(contact(nonsense))
+  end
+
+  def test_a_card_with_no_birthday_formats_none
+    bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:bare\r\nEND:VCARD\r\n"
+
+    assert_nil ProTacts::Admin::Format.birthday(contact(bare))
   end
 
   def test_time_ago_buckets_by_how_long_ago
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index b4ae63c..8344257 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -7,6 +7,16 @@ require "pro_tacts/contact"
 class ContactTest < Minitest::Test
   CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
 
+  STRUCTURED = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
+    "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
+    "EMAIL;TYPE=home:ada@example.com\r\n" \
+    "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
+    "BDAY:1985-12-10\r\nNOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  def contact(vcard)
+    ProTacts::Contact.for(id: "aiden", vcard:)
+  end
+
   def test_the_etag_hashes_the_card
     contact = ProTacts::Contact.for(id: "aiden", vcard: CARD)
 
@@ -42,4 +52,109 @@ class ContactTest < Minitest::Test
       assert_equal id, ProTacts::Contact.for(id:, vcard: CARD).id
     end
   end
+
+  # The parse is lazy so the serving paths never pay it: ctag and the
+  # listings build a contact per row without reading structure, and a
+  # card that will not parse is still served byte for byte.
+  def test_making_a_contact_never_parses_the_card
+    assert_equal "aiden", contact("not a vCard at all").id
+  end
+
+  def test_reads_the_name
+    assert_equal "Ada Lovelace", contact(STRUCTURED).name
+  end
+
+  def test_reads_the_name_components
+    assert_equal ["Lovelace", "Ada", "", "", ""], contact(STRUCTURED).name_components
+  end
+
+  def test_values_come_back_unescaped
+    escaped = STRUCTURED.sub("FN:Ada Lovelace", "FN:Ada\\, Countess of Lovelace")
+
+    assert_equal "Ada, Countess of Lovelace", contact(escaped).name
+  end
+
+  def test_reads_every_phone_with_its_type
+    assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
+      contact(STRUCTURED).phones.map { [it.value, it.type] }
+  end
+
+  def test_reads_email
+    emails = contact(STRUCTURED).emails
+    assert_equal 1, emails.length
+    assert_equal "ada@example.com", emails.fetch(0).value
+    assert_equal "home", emails.fetch(0).type
+  end
+
+  def test_reads_an_address_as_its_seven_components
+    address = contact(STRUCTURED).addresses.fetch(0)
+    assert_equal "", address.po_box
+    assert_equal "", address.extended
+    assert_equal "12 Analytical Way", address.street
+    assert_equal "London", address.locality
+    assert_equal "England", address.region
+    assert_equal "NW1 1AA", address.postal_code
+    assert_equal "United Kingdom", address.country
+    assert_equal "home", address.type
+  end
+
+  # Components the card's value stopped short of are nil, not empty —
+  # only a delimiter the value actually carried makes an empty one.
+  def test_address_components_beyond_the_value_are_nil
+    short = STRUCTURED.sub(
+      "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+      "ADR:;;12 Analytical Way",
+    )
+    address = contact(short).addresses.fetch(0)
+
+    assert_nil address.locality
+    assert_nil address.region
+    assert_nil address.postal_code
+    assert_nil address.country
+  end
+
+  def test_the_birthday_is_the_model_the_store_composed
+    assert_equal ProTacts::Birthday.new(year: 1985, month: 12, day: 10), contact(STRUCTURED).birthday
+  end
+
+  # A BDAY in a spelling the model does not recompose stays in the card
+  # verbatim; the accessor reads nil, and showing it as stored is the
+  # substrate's job (Format.birthday).
+  def test_an_unmodeled_birthday_spelling_reads_nil
+    unmodeled = STRUCTURED.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+
+    assert_nil contact(unmodeled).birthday
+  end
+
+  def test_unescapes_notes
+    assert_equal "Countess, mathematician.", contact(STRUCTURED).notes
+  end
+
+  def test_properties_are_the_parsed_card
+    properties = contact(STRUCTURED).properties
+
+    assert_equal "BEGIN", properties.fetch(0).name
+    assert properties.any? { it.name.casecmp?("TEL") }
+  end
+
+  def test_attributes_with_no_data_are_nil_or_empty
+    bare = contact("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:bare\r\nEND:VCARD\r\n")
+
+    assert_nil bare.name
+    assert_nil bare.name_components
+    assert_empty bare.phones
+    assert_empty bare.emails
+    assert_empty bare.addresses
+    assert_nil bare.birthday
+    assert_nil bare.notes
+  end
+
+  # Unlike Store#rebuild_index, which fails a card open because a
+  # write already guarantees every stored card parses (VCard.card? is
+  # checked before a PUT is accepted) — a card that reaches here and
+  # still won't parse is a bug or a corrupt row, not the ordinary case,
+  # and it should raise into Sentry rather than render a blank card.
+  def test_a_card_that_will_not_parse_raises_from_the_accessors
+    assert_raises(ProTacts::VCard::ParseError) { contact("not a vCard at all").phones }
+  end
 end