contact: carry handles, Signal's among them, as IMPP lines
A Signal username had nowhere to live on a card. Contact reads every
IMPP as a Handle (service, handle, and the URI scheme it sits under);
the record card shows each under its service, and the editor edits a
handle and its service as two fields of one row, the add dialog's
`handle` the same. A handle is written as Contacts.app spells a service
it has no scheme for: IMPP;X-SERVICE-TYPE=<service>:x-apple:<handle>.

Imports keep IMPP instead of striking it and a merge holds many. A
Monica field filed under a type named Signal comes in as a handle, and
Monica's other social fields are no longer written as X-SOCIALPROFILE.
The spelling is inferred rather than recorded; the plan says what
settles it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U77d1jrGYSKCTjsYWetd5d
change
commit b4fd3d5873f53e7414e9bdcb03cde909708934df
author Claude <noreply@anthropic.com>
date
parent bbdb64e2
diff --git a/docs/plans/2026-10-18-signal-handles.md b/docs/plans/2026-10-18-signal-handles.md
new file mode 100644
index 0000000..b1a7687
--- /dev/null
+++ b/docs/plans/2026-10-18-signal-handles.md
@@ -0,0 +1,97 @@
+# A contact can carry a Signal handle
+
+2026-10-18. A Signal username had nowhere to go: no property this book
+reads holds one, and the importers dropped the line a client would
+write it as. `Contact` grows `#handles`, read off `IMPP`; the record
+card shows each one under its service, and the editor edits a handle
+and its service both, any service rather than Signal's alone. No card
+already in the store is rewritten.
+
+## The line is Apple's
+
+A handle is written as
+
+```
+IMPP;X-SERVICE-TYPE=signal:x-apple:alice.42
+```
+
+`IMPP` is the property for an instant-messaging address (RFC 6350
+section 6.4.3; RFC 4770 gave it to 3.0, the version this book serves).
+Its value is a URI, and Signal has no scheme for a username: a
+`signal.me` link encodes the name rather than spelling it. Contacts.app
+writes a service it has no scheme for under its own `x-apple:` scheme,
+the service's name in `X-SERVICE-TYPE` — the spelling the import
+fixture's `X-SOCIALPROFILE;type=twitter:x-apple:incompleteness` shows
+the same client using for a profile. Writing the client's own spelling
+is what lets it show the line as a Signal handle rather than keep it as
+a property it has no field for.
+
+The service is written as it was typed, and lowercase where this server
+picks it — the Monica import's `signal` — as that fixture's
+`type=twitter` is and as the screens caption every other kind. The
+reader shows a service in whatever case the card carries it, so a
+handle Contacts writes as `Signal` reads as that.
+
+The alternative was a property of our own, `X-SIGNAL:alice.42`. A
+client keeps an X- property it does not know (RFC 6352 section
+6.3.2.2), so it would round-trip, but no client would show it, and a
+handle typed into Contacts would arrive as `IMPP` regardless — the
+phone labels' reason for reading the client's form rather than
+inventing one (`2026-09-18-phone-labels.md`, "A label is a line, not
+a parameter").
+
+The spelling, its case included, is inferred from that fixture and
+from how Contacts writes the services it does know, not recorded from
+a session that added a Signal handle. The first one recorded settles
+it; if Contacts spells it otherwise, `Contact.handle_line` and the
+reader change and the lines this server wrote are migrated to match.
+
+## Every IMPP reads, not only Signal's
+
+The reader takes every `IMPP` line: the service from `X-SERVICE-TYPE`,
+the handle from the value after its scheme, and the scheme itself kept
+on the row so that an edit writes it back. A save swaps only the handle,
+under the line's own header and scheme, the way a phone's save keeps
+its `TYPE` parameters (`2026-09-05-web-card-editor.md`). A line whose
+value has no scheme is no handle and reads as none; the card still
+carries it.
+
+Reading them all costs nothing over reading Signal's alone, and the
+lines Apple already sends for the services it does know show instead of
+staying invisible.
+
+## The service is the row's
+
+The first cut offered `signal` in the add dialog and wrote that service
+into every handle it added. A dialog naming services is a list
+someone has to keep, one entry per messenger in use, so the row
+carries its service as a field of its own instead: a handle row is two
+fields, the service and the handle, the address row's shape at a
+smaller size.
+
+A blank handle removes the line; a service alone is nothing to reach
+anyone at. A changed handle under the same service swaps under the
+line's own header and scheme. A changed service rewrites the header,
+keeping the line's group and every other parameter, and puts the handle
+under `x-apple:`: the line's old scheme belonged to the old service
+(`xmpp:` is no Signal address), and `x-apple:` is the scheme Contacts
+writes for a service it has none for. A blank service writes no
+`X-SERVICE-TYPE`, and the row reads as a bare handle.
+
+## What comes in on import
+
+`IMPP` joins the properties an uploaded `.vcf` keeps (`Import::Vcf`)
+and the ones a merge holds many of (`Import::Merge`), so a handle on an
+arriving card is no longer struck. Monica ships no Signal field type; an
+account that made one files a handle under its name, and that field is
+written as the line above.
+
+Monica's other social fields — Facebook, Twitter, a type an account
+made up — stop being written at all. They came in as `X-SOCIALPROFILE`
+lines whose one use was to be struck on the import screen, left behind
+in view (`2026-09-25-importing-from-monica.md`, "Left behind, in
+view"). Once a handle has a field, the profiles left over were judged
+not worth a struck row each, this book having no field for them. A
+vCard that carries its own `X-SOCIALPROFILE`, as Apple's and Monica
+5's do, is the card's own line, and is still struck like any other
+property this book does not read.
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 76baf9b..e60728a 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -71,6 +71,7 @@ module ProTacts
         vcard = edited_text(vcard, "NOTE", params["note"], note_of(own))
         vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
         vcard = edited_values(own.emails, "email", "EMAIL", vcard, params)
+        vcard = edited_handles(own, vcard, params)
         edited_addresses(own, vcard, params)
       end
 
@@ -186,6 +187,47 @@ module ProTacts
         vcard.insert(added)
       end
 
+      # #edited_values' walk over a handle, a row of two fields: the
+      # service and the handle itself
+      # (docs/plans/2026-10-18-signal-handles.md, "The service is the
+      # row's"). A blank handle removes the line, a service alone being
+      # nothing to reach anyone at. A changed handle under an unchanged
+      # service swaps under the line's own header and scheme, the
+      # phones' rule; a changed service rewrites the header around the
+      # line's other parameters and moves the handle under `x-apple:`,
+      # the line's old scheme naming the old service's protocol. The
+      # rows the add dialog reveals are named by their add index, the
+      # address's reason (Rack refuses a key after a bare []).
+      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+      def self.edited_handles(contact, vcard, params)
+        rows = hash_field(params, "handle")
+        contact.handles.each do |handle|
+          submitted = rows[handle.line.digest]
+          next if submitted.nil?
+
+          service = submitted["service"].to_s.strip
+          value = submitted["value"].to_s.strip
+          same_service = service == handle.service.to_s.strip
+          next if same_service && value == handle.value.strip
+
+          line =
+            if value.empty?
+              nil
+            elsif same_service
+              "#{VCard.header_of(handle.property)}#{handle.scheme}:#{VCard.escape(value)}"
+            else
+              Contact.handle_line(service, value, handle.property)
+            end
+          vcard = vcard.substitute(handle.line.digest, line ? [line] : [])
+        end
+
+        added = hash_field(params, "new_handle").values #: Array[Hash[String, untyped]]
+        vcard.insert(added.filter_map { |row|
+          value = row["value"].to_s.strip
+          Contact.handle_line(row["service"].to_s.strip, value) unless value.empty?
+        })
+      end
+
       # #edited_values' walk, over a row that is six fields rather than
       # one (Admin::ContactsEdit). Removal is the reader's own rule
       # (Contact#address_of): a row blank throughout, po box included,
@@ -411,7 +453,7 @@ module ProTacts
         "N:#{components.join(";")}"
       end
 
-      private_class_method :edited_values, :edited_addresses, :hash_field, :address_unchanged?,
+      private_class_method :edited_values, :edited_handles, :edited_addresses, :hash_field, :address_unchanged?,
                            :address_line, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
                            :n_line, :company_name, :person_name, :edited_organization
     end
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index f6fc42a..69562d4 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -97,6 +97,9 @@ module ProTacts
         @contact.emails.each do |email|
           row(Format.type_label(nil, email.types, "email"), email.value, email.lent_by)
         end
+        @contact.handles.each do |handle|
+          row(Format.type_label(handle.service, [], "handle"), handle.value, handle.lent_by)
+        end
         @contact.addresses.each do |address|
           row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.lent_by)
         end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 5f22409..011d117 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -48,7 +48,7 @@ module ProTacts
       # added row names the model's own `birthday[]` group, there
       # being no card line to insert and room for but one — see
       # #addable_types for when the dialog offers it.
-      ADDABLE_TYPES = %w[phone email address birthday].freeze #: Array[String]
+      ADDABLE_TYPES = %w[phone email handle address birthday].freeze #: Array[String]
       private_constant :ADDABLE_TYPES
 
       # The form's id, for the footer's Save outside it.
@@ -71,6 +71,16 @@ module ProTacts
         ["country", "country"],
       ].freeze #: Array[[String, String]]
 
+      # A handle row's two fields, the service first because it says
+      # what the handle is (docs/plans/2026-10-18-signal-handles.md,
+      # "The service is the row's"). Field name to placeholder, the
+      # address fields' shape.
+      HANDLE_FIELDS = [
+        ["service", "service"],
+        ["value", "handle"],
+      ].freeze #: Array[[String, String]]
+      private_constant :HANDLE_FIELDS
+
       # @rbs @contact: Contact
       # @rbs @login: String
       # @rbs @own: Contact
@@ -246,6 +256,9 @@ module ProTacts
                     value_row(Format.type_label(nil, email.types, "email"),
                               type: "email", name: "email[#{email.line.digest}]", value: email.value)
                   end
+                  @own.handles.each do
+                    handle_row(it)
+                  end
                   @own.addresses.each do
                     address_row(it)
                   end
@@ -310,7 +323,7 @@ module ProTacts
             # The single-value kinds: one input, its type and name
             # bound to the kind — the keyboard a phone number or an
             # email address wants, on the one client that has one.
-            template("x-if": "kind !== 'address' && kind !== 'birthday'") do
+            template("x-if": "kind === 'phone' || kind === 'email'") do
               input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
                     ":aria-label": "kind", "x-init": "$el.focus()")
             end
@@ -328,6 +341,17 @@ module ProTacts
                 end
               end
             end
+            # The handle kind: the standing row's two fields, named by
+            # the add index for the address's reason.
+            template("x-if": "kind === 'handle'") do
+              div(class: "field-stack") do
+                HANDLE_FIELDS.each_with_index do |(field, label), index|
+                  input(type: "text", ":name": "`new_handle[${i}][#{field}]`",
+                        placeholder: label, aria_label: label,
+                        "x-init": index.zero? ? "$el.focus()" : nil)
+                end
+              end
+            end
             # The birthday kind: the standing row's own three
             # controls with nothing prefilled, naming the model's
             # birthday[] group rather than a new_* name — no card
@@ -492,6 +516,23 @@ module ProTacts
         end
       end
 
+      # A standing handle row: the service and the handle, each a field
+      # of the row's digest, and a blank handle removes the line
+      # (CardForm.edited_handles), so the removal state is
+      # unconditional, the single-value rows' own.
+      #: (Contact::Handle handle) -> void
+      def handle_row(handle)
+        div(class: "field", data: {blank_removes: true}) do
+          span { "handle" }
+          div(class: "field-stack") do
+            HANDLE_FIELDS.each do |field, label|
+              input(type: "text", name: "handle[#{handle.line.digest}][#{field}]",
+                    value: handle.public_send(field), placeholder: label, aria_label: label)
+            end
+          end
+        end
+      end
+
       # Where a property a contact does not have yet gets named
       # (docs/DESIGN.md, "Adding is explicit"). It holds no value,
       # so there is nothing staged out of sight and Cancel is a plain
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f22592..000b19b 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -74,6 +74,14 @@ module ProTacts
     Phone = Data.define(:value, :label, :types, :line, :property, :lent_by)
     # @rbs skip
     Email = Data.define(:value, :types, :line, :property, :lent_by)
+    # A handle: an IMPP (RFC 6350 section 6.4.3, from RFC 4770 for
+    # 3.0) as Contacts.app spells one, the service its X-SERVICE-TYPE
+    # parameter and the value the handle, with the URI scheme it sits
+    # under kept apart so a save can write it back — Apple's own
+    # `x-apple:` for a service with no scheme, Signal's case
+    # (docs/plans/2026-10-18-signal-handles.md).
+    # @rbs skip
+    Handle = Data.define(:service, :value, :scheme, :line, :property, :lent_by)
     # @rbs skip
     Note = Data.define(:value, :line)
     # @rbs skip
@@ -110,6 +118,22 @@ module ProTacts
     # here.
     ADDRESS_COMPONENTS = %w[extended street locality region postal_code country].freeze #: Array[String]
 
+    # A handle's line in Contacts.app's spelling, the service in
+    # X-SERVICE-TYPE and the handle under `x-apple:`
+    # (docs/plans/2026-10-18-signal-handles.md), keeping the group and
+    # the other parameters of the line it replaces, if any. A blank
+    # service names none. The characters a parameter value cannot
+    # carry, quoted or not (RFC 2425 section 5.8.2's QSAFE-CHAR), are
+    # dropped; VCard.header_of quotes what needs it.
+    #: (String service, String value, ?VCard::Parser::Property? replaced) -> String
+    def self.handle_line(service, value, replaced = nil)
+      service = service.gsub(/[\x00-\x1F\x7F"]/, "")
+      kept = (replaced&.parameters || []).reject { |name, _| name.casecmp?("X-SERVICE-TYPE") }
+      parameters = service.empty? ? kept : [*kept, ["X-SERVICE-TYPE", service]] #: Array[[String, String]]
+      property = VCard::Parser::Property.new(group: replaced&.group, name: "IMPP", parameters:, value: "")
+      "#{VCard.header_of(property)}x-apple:#{VCard.escape(value)}"
+    end
+
     #: (VCard vcard) -> String
     def self.etag_for(vcard)
       %("#{Digest::SHA256.hexdigest(vcard.to_s)}")
@@ -391,6 +415,19 @@ module ProTacts
       }
     end
 
+    # A line whose value carries no scheme is no URI and no handle
+    # this reader can split, and reads as none — the property stays
+    # the card's own, shown by #properties.
+    #: () -> Array[Handle]
+    def handles
+      rows("IMPP") { |property, line, lent_by|
+        scheme, handle = text_of(property)&.split(":", 2)
+        next if scheme.nil? || handle.nil? || handle.empty?
+
+        Handle.new(service: property.parameter("X-SERVICE-TYPE"), value: handle, scheme:, line:, property:, lent_by:)
+      }
+    end
+
     #: () -> Array[Address]
     def addresses
       rows("ADR") { |property, line, lent_by| address_of(property, line:, lent_by:) }
diff --git a/lib/pro_tacts/import/merge.rb b/lib/pro_tacts/import/merge.rb
index 31a9e05..0ec97c6 100644
--- a/lib/pro_tacts/import/merge.rb
+++ b/lib/pro_tacts/import/merge.rb
@@ -25,7 +25,7 @@ module ProTacts
 
       # What a contact holds many of: an arriving one joins the
       # contact's unless the contact already has its value.
-      MANY = %w[TEL EMAIL ADR].freeze #: Array[String]
+      MANY = %w[TEL EMAIL IMPP ADR].freeze #: Array[String]
 
       # What a contact holds one of: an arriving one fills a gap, and
       # is left behind where the contact has its own.
diff --git a/lib/pro_tacts/import/monica.rb b/lib/pro_tacts/import/monica.rb
index 4f30ea1..ed223dd 100644
--- a/lib/pro_tacts/import/monica.rb
+++ b/lib/pro_tacts/import/monica.rb
@@ -1,6 +1,7 @@
 require "json"
 
 require "pro_tacts/birthday"
+require "pro_tacts/contact"
 require "pro_tacts/import/vcf"
 require "pro_tacts/vcard"
 
@@ -189,11 +190,13 @@ module ProTacts
       end
 
       # A contact field: a phone or an email by the type Monica filed it
-      # under, and anything else — Facebook, Twitter, a type the account
-      # made up — as a social profile named for that type, which no
-      # screen here shows. A field naming a type the export does not
-      # hold is left behind, its value with it: guessing a kind for it
-      # would file a phone as a profile nobody sees.
+      # under, a Signal handle by a type the account named Signal
+      # (Monica ships no such type), and nothing for anything else —
+      # Facebook, Twitter, another type the account made up — which no
+      # screen here shows (docs/plans/2026-10-18-signal-handles.md,
+      # "What comes in on import"). A field naming a type the export
+      # does not hold is left behind, its value with it: guessing a kind
+      # for it would file a phone as a handle nobody meant.
       #: (Hash[String, untyped] field, Hash[String, untyped]? type) -> String?
       def self.contact_field(field, type)
         value = field["data"].to_s.strip
@@ -203,7 +206,7 @@ module ProTacts
         case type["type"]
         when "phone" then "TEL:#{VCard.escape(value)}"
         when "email" then "EMAIL;type=INTERNET:#{VCard.escape(value)}"
-        else "X-SOCIALPROFILE;type=#{type["name"].to_s.gsub(/[^\w-]/, "")}:#{VCard.escape(value)}"
+        else Contact.handle_line("signal", value) if type["name"].to_s.strip.casecmp?("signal")
         end
       end
 
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index a293cbd..6b7c6b1 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -30,7 +30,7 @@ module ProTacts
       # store takes into the model on the way in
       # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
       KNOWN = [
-        *%w[BEGIN END VERSION UID N FN NICKNAME BDAY TEL EMAIL ADR NOTE PHOTO ORG X-ABSHOWAS],
+        *%w[BEGIN END VERSION UID N FN NICKNAME BDAY TEL EMAIL IMPP ADR NOTE PHOTO ORG X-ABSHOWAS],
         LABEL,
       ].freeze #: Array[String]
 
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 82f52b5..d362581 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -195,6 +195,10 @@ module ProTacts
         rows = typecast_params.Hash(key)
         rows&.each_key { typecast_params[key][it].str(Contact::ADDRESS_COMPONENTS) }
       end
+      %w[handle new_handle].each do |key|
+        rows = typecast_params.Hash(key)
+        rows&.each_key { typecast_params[key][it].str(%w[service value]) }
+      end
       typecast_params["birthday"].str(%w[year month day]) if typecast_params.Hash("birthday")
       request.params
     end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 2af6dc6..c16405c 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,5 +1,5 @@
-# Contact::Phone, ::Email, ::Note, ::Inherited, ::Photo, and ::Address are
-# Data classes, whose members the inline parser cannot read: Data.define
+# Contact::Phone, ::Email, ::Handle, ::Note, ::Inherited, ::Photo,
+# and ::Address are Data classes, whose members the inline parser cannot read: Data.define
 # leaves them no constant super class. Each is marked `@rbs skip` in
 # contact.rb and signed here; Contact itself is a plain class and
 # carries its signature inline.
@@ -26,6 +26,17 @@ module ProTacts
       def self.new: (value: String, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
     end
 
+    class Handle
+      attr_reader service: String?
+      attr_reader value: String
+      attr_reader scheme: String
+      attr_reader line: VCard::Parser::Line
+      attr_reader property: VCard::Parser::Property
+      attr_reader lent_by: Group?
+
+      def self.new: (service: String?, value: String, scheme: String, line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
+    end
+
     class Note
       attr_reader value: String
       attr_reader line: VCard::Parser::Line
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 3915856..5850c00 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -2148,6 +2148,144 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  ## Editing handles
+
+  SIGNAL = ADA.sub("BDAY:", "IMPP;X-SERVICE-TYPE=signal:x-apple:ada.42\r\nBDAY:")
+
+  # A handle shows under its service, the way a labeled phone shows
+  # under its label.
+  def test_show_lists_a_handle_under_its_service
+    with_contacts({"ada" => SIGNAL}) do
+      get "/contacts/ada"
+
+      assert_includes last_response.body, '<dt class="type-label">signal</dt><dd class="type-body-sm"><div style="white-space: pre-wrap;">ada.42</div></dd>'
+    end
+  end
+
+  # Two fields per handle, the service and the handle, each named by
+  # the line's digest; the scheme is the line's, like its parameters,
+  # and no field models it.
+  def test_the_edit_screen_renders_a_service_and_a_handle_field_per_handle
+    with_contacts({"ada" => SIGNAL}) do |store|
+      get "/contacts/ada/edit"
+
+      digest = store.contact("ada").handles.first.line.digest
+      assert_includes last_response.body,
+        '<div class="field" data-blank-removes><span>handle</span><div class="field-stack">' \
+        '<input type="text" name="handle[' + digest + '][service]" value="signal" placeholder="service" aria-label="service">' \
+        '<input type="text" name="handle[' + digest + '][value]" value="ada.42" placeholder="handle" aria-label="handle">' \
+        '</div></div>'
+    end
+  end
+
+  def post_handle(store, service:, value:)
+    digest = store.contact("ada").handles.first.line.digest
+    post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", note: "Countess of Lovelace.",
+                             etag: store.contact("ada").etag,
+                             handle: {digest => {"service" => service, "value" => value}}
+    assert_equal 303, last_response.status
+    store.contact("ada").vcard.to_s
+  end
+
+  # A changed handle lands under the line's own header and scheme.
+  def test_saving_a_changed_handle_keeps_its_service_and_scheme
+    with_contacts({"ada" => SIGNAL.sub("IMPP;", "item1.IMPP;type=pref;")}) do |store|
+      card = post_handle(store, service: "signal", value: "countess.01")
+
+      assert_includes card, "item1.IMPP;type=pref;X-SERVICE-TYPE=signal:x-apple:countess.01\r\n"
+      refute_includes card, "ada.42"
+    end
+  end
+
+  # A changed service keeps the line's group and other parameters and
+  # moves the handle under x-apple:, the old scheme being the old
+  # service's (docs/plans/2026-10-18-signal-handles.md, "The service
+  # is the row's").
+  def test_saving_a_changed_service_rewrites_the_header_and_scheme
+    xmpp = ADA.sub("BDAY:", "item1.IMPP;type=pref;X-SERVICE-TYPE=Jabber:xmpp:ada@example.com\r\nBDAY:")
+
+    with_contacts({"ada" => xmpp}) do |store|
+      card = post_handle(store, service: "Delta Chat", value: "ada@example.com")
+
+      assert_includes card, "item1.IMPP;type=pref;X-SERVICE-TYPE=Delta Chat:x-apple:ada@example.com\r\n"
+      refute_includes card, "Jabber"
+    end
+  end
+
+  def test_a_blank_service_writes_none
+    with_contacts({"ada" => SIGNAL}) do |store|
+      card = post_handle(store, service: " ", value: "ada.42")
+
+      assert_includes card, "IMPP:x-apple:ada.42\r\n"
+      assert_equal [[nil, "ada.42"]], store.contact("ada").handles.map { [it.service, it.value] }
+    end
+  end
+
+  def test_a_blank_handle_removes_the_line_whatever_its_service
+    with_contacts({"ada" => SIGNAL}) do |store|
+      card = post_handle(store, service: "signal", value: "")
+
+      refute_includes card, "IMPP"
+    end
+  end
+
+  def test_an_unchanged_handle_leaves_the_stored_card_byte_identical
+    with_contacts({"ada" => SIGNAL}) do |store|
+      before = store.contact("ada").stored.to_s
+      post_handle(store, service: "signal", value: "ada.42")
+
+      assert_equal before, store.contact("ada").stored.to_s
+    end
+  end
+
+  # Each handle row posts as a hash of its two fields; any other shape
+  # is a 400, the address rows' own rule.
+  def test_a_handle_field_in_a_shape_the_form_never_sends_is_a_400
+    with_contacts({"ada" => SIGNAL}) do |store|
+      contact = store.contact("ada")
+      digest = contact.handles.first.line.digest
+      [{handle: {digest => "ada.42"}},
+       {handle: "ada.42"},
+       {new_handle: ["ada.42"]}].each do |field|
+        post "/contacts/ada", {first: "Ada", last: "Lovelace", company: "0", etag: contact.etag}.merge(field)
+
+        assert_equal 400, last_response.status, field
+        assert_equal contact.etag, store.contact("ada").etag
+      end
+    end
+  end
+
+  # The revealed rows write each service as typed, in Contacts.app's
+  # spelling of a handle (docs/plans/2026-10-18-signal-handles.md),
+  # and a row with no handle inserts nothing.
+  def test_added_handles_land_as_impp_lines_before_the_end
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
+                               new_handle: {"0" => {"service" => "signal", "value" => "ada.42"},
+                                            "1" => {"service" => "signal", "value" => ""},
+                                            "2" => {"service" => "WhatsApp", "value" => "+1-555-0100"}}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s,
+        "IMPP;X-SERVICE-TYPE=signal:x-apple:ada.42\r\n" \
+        "IMPP;X-SERVICE-TYPE=WhatsApp:x-apple:+1-555-0100\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n"
+      assert_equal [["signal", "ada.42"], ["WhatsApp", "+1-555-0100"]],
+        store.contact("ada").handles.map { [it.service, it.value] }
+    end
+  end
+
+  # A quote is no character a parameter value can carry, quoted or
+  # not, so it is dropped rather than ending the value early.
+  def test_an_added_service_drops_what_a_parameter_cannot_carry
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
+                               new_handle: {"0" => {"service" => %(Sig"nal), "value" => "ada.42"}}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s, "IMPP;X-SERVICE-TYPE=Signal:x-apple:ada.42\r\n"
+    end
+  end
+
   ## Editing addresses
 
   # An address row: the type's caption and a stack of component
@@ -2598,6 +2736,7 @@ class AdminContactsPagesTest < Minitest::Test
         '<div class="field-stack" role="radiogroup" aria-label="Type">' \
         '<label><input type="radio" name="add-type" value="phone" x-model="type">phone</label>' \
         '<label><input type="radio" name="add-type" value="email" x-model="type">email</label>' \
+        '<label><input type="radio" name="add-type" value="handle" x-model="type">handle</label>' \
         '<label><input type="radio" name="add-type" value="address" x-model="type">address</label></div>'
       assert_includes body,
         '<button type="button" popovertarget="add-property" popovertargetaction="hide">Cancel</button>'
@@ -2627,7 +2766,7 @@ class AdminContactsPagesTest < Minitest::Test
       assert_includes body,
         '<template x-for="(kind, i) in added" :key="i"><div class="field">' \
         '<span x-text="kind"></span>' \
-        '<template x-if="kind !== \'address\' && kind !== \'birthday\'">' \
+        '<template x-if="kind === \'phone\' || kind === \'email\'">' \
         '<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
         '</template>' \
         '<template x-if="kind === \'address\'">' \
@@ -2639,6 +2778,13 @@ class AdminContactsPagesTest < Minitest::Test
         '<input type="text" :name="`new_address[${i}][postal_code]`" placeholder="postal code" aria-label="postal code">' \
         '<input type="text" :name="`new_address[${i}][country]`" placeholder="country" aria-label="country">' \
         '</div></template>'
+      # The handle kind: the standing row's two fields, named by the
+      # add index as the address's are.
+      assert_includes body,
+        '<template x-if="kind === \'handle\'"><div class="field-stack">' \
+        '<input type="text" :name="`new_handle[${i}][service]`" placeholder="service" aria-label="service" x-init="$el.focus()">' \
+        '<input type="text" :name="`new_handle[${i}][value]`" placeholder="handle" aria-label="handle">' \
+        '</div></template>'
       # The birthday kind: the standing row's own controls with
       # nothing prefilled, naming the model's birthday[] group — no
       # digest, a contact holding at most one, and no removal state,
diff --git a/test/pro_tacts/import/test_merge.rb b/test/pro_tacts/import/test_merge.rb
index 8039469..4bca8a7 100644
--- a/test/pro_tacts/import/test_merge.rb
+++ b/test/pro_tacts/import/test_merge.rb
@@ -66,6 +66,16 @@ class ImportMergeTest < Minitest::Test
     assert_equal ["555-010-0100", "555 010 0199"], phones
   end
 
+  # A handle is one of many, like an email: a new one comes in, and
+  # one the contact has does not come in twice.
+  def test_a_handle_it_lacks_comes_in_and_one_it_has_does_not
+    signal = "IMPP;X-SERVICE-TYPE=signal:x-apple:jb.01\r\n"
+    into = jane(STORED.sub("UID:", "#{signal}UID:"))
+    arriving = ARRIVING.sub("UID:", "#{signal}IMPP;X-SERVICE-TYPE=signal:x-apple:janet.02\r\nUID:")
+
+    assert_equal ["jb.01", "janet.02"], merge(into, arriving).contact.handles.map(&:value)
+  end
+
   # Apple numbers `item` groups per card, so the arriving email's
   # item1 would collide with the contact's own: it is renumbered, and
   # its label goes with it.
diff --git a/test/pro_tacts/import/test_monica.rb b/test/pro_tacts/import/test_monica.rb
index 6663eb2..6d3015b 100644
--- a/test/pro_tacts/import/test_monica.rb
+++ b/test/pro_tacts/import/test_monica.rb
@@ -17,6 +17,7 @@ class ImportMonicaTest < Minitest::Test
   PHONE = "type-phone"
   EMAIL = "type-email"
   TWITTER = "type-twitter"
+  SIGNAL = "type-signal"
 
   # A red pixel, as the data URL Monica's Photo resource writes.
   PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
@@ -89,6 +90,7 @@ class ImportMonicaTest < Minitest::Test
             {"uuid" => PHONE, "properties" => {"name" => "Phone", "type" => "phone"}},
             {"uuid" => EMAIL, "properties" => {"name" => "Email", "type" => "email"}},
             {"uuid" => TWITTER, "properties" => {"name" => "Twitter"}},
+            {"uuid" => SIGNAL, "properties" => {"name" => "Signal"}},
           ],
         },
       },
@@ -129,6 +131,26 @@ class ImportMonicaTest < Minitest::Test
     assert_equal PIXEL, card.property("PHOTO").value
   end
 
+  # A social profile is not written at all: no screen here shows one,
+  # and the import says nothing of it.
+  def test_a_social_profile_is_not_written
+    refute_includes Monica.vcf(export(jane)), "janeb"
+  end
+
+  # Monica ships no Signal type, so a handle is filed under one the
+  # account made, and comes in as the line the editor writes
+  # (docs/plans/2026-10-18-signal-handles.md).
+  def test_a_field_filed_under_signal_comes_in_as_a_handle
+    kim = {
+      "uuid" => "kim-uuid",
+      "properties" => {"first_name" => "Kim"},
+      "data" => [collection("contact_field", [{"uuid" => "f9", "properties" => {"data" => "kim.07", "type" => SIGNAL}}])],
+    }
+    card = Vcf.read(card_for(export(kim), "Kim")).card
+
+    assert_includes card.to_s, "IMPP;X-SERVICE-TYPE=signal:x-apple:kim.07\r\n"
+  end
+
   # A company Monica kept as a vCard's raw ORG loses the trailing
   # semicolons, which escaped as text would be part of its name; one
   # that was nothing else is no company.
@@ -177,7 +199,6 @@ class ImportMonicaTest < Minitest::Test
     dropped = Vcf.read(card_for(export(jane), "Jane Booles")).dropped.map { Vcf.summary(it) }
 
     assert_includes dropped, "TITLE:Welder"
-    assert_includes dropped, "X-SOCIALPROFILE;type=Twitter:janeb"
     assert_includes dropped, "X-MONICA-REMINDER:2026-10-01 — Call about the dish"
     assert_includes dropped, "X-MONICA-ACTIVITY:2026-06-01 — Picnic"
   end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index 107146a..3f17892 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -166,6 +166,16 @@ class ImportVcfTest < Minitest::Test
     assert Vcf.cards(revised).fetch(0).card?
   end
 
+  # A Signal handle, and any other IMPP, is a row a screen here shows
+  # (docs/plans/2026-10-18-signal-handles.md).
+  def test_reading_a_card_keeps_its_handles
+    handle = JANE.sub("UID:", "IMPP;X-SERVICE-TYPE=signal:x-apple:jb.01\r\nUID:")
+    read = Vcf.read(Vcf.cards(handle).fetch(0))
+
+    assert_includes read.card.to_s, "IMPP;X-SERVICE-TYPE=signal:x-apple:jb.01\r\n"
+    refute(read.dropped.any? { it.verbatim.start_with?("IMPP") })
+  end
+
   # A company card is read by its flag and its ORG
   # (docs/plans/2026-09-24-company-cards.md), so both come in and
   # neither is a decision; without them it would arrive as a person.
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 3b45804..25b87e9 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -242,6 +242,29 @@ class ContactTest < Minitest::Test
     assert contact(broken).vcard.lines.any? { it.names?("EMAIL") && it.property.nil? }
   end
 
+  def test_reads_a_signal_handle_under_its_service
+    signal = STRUCTURED.sub("UID:", "item1.IMPP;X-SERVICE-TYPE=signal:x-apple:ada.42\r\nUID:")
+    message = contact(signal).handles.fetch(0)
+
+    assert_equal ["signal", "ada.42", "x-apple"], [message.service, message.value, message.scheme]
+    assert_equal "item1.IMPP;X-SERVICE-TYPE=signal:x-apple:ada.42\r\n", message.line.verbatim
+  end
+
+  # A service with a scheme of its own keeps it, and one the card
+  # names no service for has none.
+  def test_a_handle_keeps_its_own_scheme
+    xmpp = STRUCTURED.sub("UID:", "IMPP:xmpp:ada@example.com\r\nUID:")
+
+    message = contact(xmpp).handles.fetch(0)
+    assert_equal [nil, "ada@example.com", "xmpp"], [message.service, message.value, message.scheme]
+  end
+
+  def test_an_impp_with_no_scheme_or_no_handle_is_no_handle
+    bare = STRUCTURED.sub("UID:", "IMPP;X-SERVICE-TYPE=signal:ada.42\r\nIMPP:x-apple:\r\nUID:")
+
+    assert_empty contact(bare).handles
+  end
+
   def test_reads_an_address_as_its_seven_components
     address = contact(STRUCTURED).addresses.fetch(0)
     assert_nil address.po_box