Let organizations arrive by client and import, not the web UI
Every web-side create affordance cost the person dialog more than the
rare case was worth, and the client path is the real one: the recorded
macOS 27 PUT writes a finished company card in two steps. The real wire
shape leaves N empty with the name in FN and ORG both, so the editor
now prefills its Organization field from whichever holds it, and
test/fixtures/cards/company.vcf seeds those bytes.

Assisted-by: GLM-5.3 via pi
change kupunlpvzsztotukvyuustnsypvvkyqp
commit e18cadc8ffc5e8a2816f79b8172e149f55b02a9c
author Alpha Chen <alpha@kejadlen.dev>
date
parent vlwzvkzw
diff --git a/docs/plans/2026-09-24-company-cards.md b/docs/plans/2026-09-24-company-cards.md
index e8617a5..5c6973b 100644
--- a/docs/plans/2026-09-24-company-cards.md
+++ b/docs/plans/2026-09-24-company-cards.md
@@ -16,37 +16,50 @@ carries `ORG` without the flag, and nothing binds the two.
 3.5.5) — what a search reads, no screen having asked for the
 components apart yet.
 
+The wire shape is recorded, not remembered: the PUT a real client
+sent (log/dev.log, 2026-09-24; macOS 27.0) writes a company card
+with `N` empty — `N:;;;;` — the name in `FN` and `ORG` both, and the
+flag. `test/fixtures/cards/company.vcf` carries those bytes. The
+older shape this task's description recalled, the name in N's family
+slot, is handled too — the editor reads whichever holds it — but no
+client here has been seen sending it.
+
 ## Creating
 
-The dashboard's new-contact dialog carries a quiet "New company"
-link at the foot of its actions, opening a second popover of its own:
-one Organization field, the same Create. The first cut was a Company
-checkbox swapping the name fields inside the one dialog, and it was
-wrong twice — a mode switch inside a three-field form, and a popover
-resizing around its own content every time it flipped. The second
-dialog is the honest shape: the person's dialog is exactly what it
-was, and the swap is whole popovers (`popover: "auto"` closes the
-first when the second opens), not fields inside one. The org form
-posts `company` with the one name, and `CardForm.new_org_card` writes
-the shape Contacts.app emits: the name in N's family slot, `FN` and
-`ORG` of the same, the flag. An organization is made here, not by
-converting a person later — the editor never flips the flag.
+There is no web-side create for an organization, and not for want
+of trying: a Company checkbox inside the dialog rendered wrong
+(a checkbox is no Gloss Field control), rendered right it made the
+popover lurch (a popover sizes to its content, and the toggle
+swapped three fields for one), and a second popover of its own
+doubled the dialog for a rare case. Each shape cost the common
+path more than the rare one was worth. What made the cut easy is
+that the other paths are the real ones: a client's own Company
+flow PUTs a finished card — proven on the wire, including the
+two-step create where the first PUT is an empty shell and the name
+lands with the second — and import is the bulk path, once `KNOWN`
+lets `ORG` and the flag through.
 
 ## Editing
 
 A company card's editor shows the one Organization field where the
-pair stands. Its wire name is `last`, because that is the truth of
-where the name lives — N's family slot, the component `CardForm.n_line`
-splices — and the existing save machinery needed nothing: the field
-submits as the family half, `edited_fn` rewrites `FN` when it moves,
-and the nameless backstop refuses a blank.
+pair stands. Its wire name is `last`, because that is where the save
+writes the name — N's family slot, the component `CardForm.n_line`
+splices — and the existing save machinery needed nothing beyond the
+prefill: the field submits as the family half, `edited_fn` rewrites
+`FN` when it moves, and the nameless backstop refuses a blank.
+
+The prefill reads the name from wherever the card holds it — the
+family slot, then `FN`, then `ORG`'s own name — because the real
+wire shape (above) leaves `N` empty, and a field prefilled from the
+family slot alone would have opened blank and refused its own save.
+An untouched save on such a card writes the name into `N` as its
+side effect, which is the editor's contract kept: the field owns
+the name, and the name now lives where the field writes it.
 
 One policy, stated plainly: a save does not touch `ORG`. The editor
 writes the lines its fields own, and `ORG` is not one of them — a
 renamed company can drift from the `ORG` it arrived with, invisible
-here (no screen renders `ORG`) and editable on any client. The
-create writes it because a created card has no prior octets to
-preserve.
+here (no screen renders `ORG`) and editable on any client.
 
 ## Search
 
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 5459963..2d82f5e 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -33,25 +33,6 @@ module ProTacts
         )
       end
 
-      # A created organization's card, the shape Contacts.app emits
-      # for one: the name in N's family slot, FN of the same, ORG
-      # carrying it again, and Apple's X-ABShowAs:COMPANY flag — the
-      # spelling a synced client needs to show it as a company
-      # (Contact#company?). One name rather than a pair: an
-      # organization is known by one name, the mononym's own rule.
-      #: (String id, String name) -> VCard
-      def self.new_org_card(id, name)
-        VCard.new(
-          "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
-            "N:#{VCard.escape(name)};;;;\r\n" \
-            "FN:#{VCard.escape(name)}\r\n" \
-            "ORG:#{VCard.escape(name)}\r\n" \
-            "X-ABShowAs:COMPANY\r\n" \
-            "UID:#{id}\r\n" \
-            "END:VCARD\r\n",
-        )
-      end
-
       # The contact editor's save, spliced into the stored card.
       #: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
       def self.contact_card(contact, first, middle, last, params)
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index ab1ee7e..350640b 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -42,13 +42,12 @@ module ProTacts
     # `autofocus` fires when the popover is shown (it is scoped to the
     # popover, so the header search's own page-load autofocus stands).
     #
-    # An organization is made by its own popover beside this one
-    # (docs/plans/2026-09-24-company-cards.md), opened from the
-    # footer's link — not by a toggle swapping the name fields, which
-    # made the popover lurch around its own content-sized box. Created
-    # here rather than converted from a person later: the editor
-    # renders a company card's one name field, and nothing flips the
-    # flag.
+    # No company variant of this dialog: organizations are created by
+    # the clients (Contacts.app's own Company flow PUTs a finished
+    # card) and by import, and each web-side affordance tried for one
+    # cost the common path chrome it could not pay for
+    # (docs/plans/2026-09-24-company-cards.md, "Creating"). The editor
+    # knows a company card when it edits one (Contact#company?).
     class ContactDialog < Phlex::HTML
       # @rbs @query: String
 
@@ -80,46 +79,12 @@ module ProTacts
             input(type: "hidden", name: "q", value: @query)
           end
           footer do
-            # The company create's way in, on the footer's left (Gloss
-            # right-justifies a dialog's actions; this is the one
-            # thing on the other side). Opening the second popover
-            # closes this one, `popover: "auto"` being the many-open-
-            # at-once refusal, so the swap is whole dialogs rather
-            # than fields inside one — a popover sizes to its content,
-            # and fields swapping inside it made the box lurch.
-            button(type: "button", style: "margin-right: auto",
-                   popovertarget: "new-company") { "New company" }
             button(type: "button", popovertarget: "new-contact",
                    popovertargetaction: "hide") { "Cancel" }
             button(type: "submit", form: "new-contact-form",
                    data: {variant: "primary"}) { "Create" }
           end
         end
-
-        # The company create's own popover, the person dialog's footer
-        # link the way in. One field — an organization is known by one
-        # name, the mononym's own rule — and the hidden `company` the
-        # route branches on (CardForm.new_org_card). The same popover
-        # machinery as the person's: Popover API open and close, no
-        # script, values surviving a light dismiss, autofocus scoped
-        # to the popover once shown.
-        dialog(id: "new-company", popover: "auto") do
-          header { "New company" }
-          form(id: "new-company-form", action: "/contacts", method: "post") do
-            input(type: "hidden", name: "company", value: "1")
-            label(class: "field") do
-              plain "Organization"
-              input(type: "text", name: "organization", required: true, autofocus: true)
-            end
-            input(type: "hidden", name: "q", value: @query)
-          end
-          footer do
-            button(type: "button", popovertarget: "new-company",
-                   popovertargetaction: "hide") { "Cancel" }
-            button(type: "submit", form: "new-company-form",
-                   data: {variant: "primary"}) { "Create" }
-          end
-        end
       end
     end
   end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 552ffa2..65e8c79 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -128,7 +128,7 @@ module ProTacts
         family, given, additional = contact.name_components || []
         @first = given
         @middle = additional
-        @last = family
+        @last = family || organization_name
       end
 
       def view_template
@@ -248,6 +248,20 @@ module ProTacts
 
       private
 
+      # A company card's name, from wherever the card holds it. macOS
+      # writes N empty and the name in FN and ORG both (the PUT a real
+      # client sent, log/dev.log 2026-09-24), so the organization
+      # field cannot prefill from the family slot alone; the older
+      # family-slot shape is already read by then, and ORG's own name
+      # is its first component — the semicolons after it are units.
+      # nil for a person, whose family slot is the field.
+      #: () -> String?
+      def organization_name
+        return nil unless @contact.company?
+
+        @contact.name || @contact.organization&.split(";", 2)&.first
+      end
+
       # The rows added this pass, rendered by Alpine from `added` —
       # one per type named in the dialog, in the order they were
       # asked for. Nothing stands here until something is added and
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 718b4cb..44e1e3f 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -33,40 +33,25 @@ module ProTacts
         # names. Stored through Store#put like any client write, so
         # the change log a sync token counts on lands with the card.
         # A nameless create is a dashboard re-render with a toast: the
-        # browser cannot produce one — the dialog requires one name box
-        # or the other, Admin::NamePair, or the company dialog's one
-        # organization box — so this is the backstop, and
+        # browser cannot produce one (the dialog requires one name box
+        # or the other, Admin::NamePair), so this is the backstop, and
         # a popover cannot be declared open in markup — the toast is
         # the refusal the
         # re-rendered page can actually show.
         r.post do
-          # The company dialog's create: one name, the card
-          # CardForm.new_org_card writes. The same nameless backstop,
-          # for the same reason.
-          if r.params["company"]
-            organization = r.params["organization"].to_s.strip
-            if organization.empty?
-              dashboard(query: r.params["q"], notice: "A contact needs a name.")
-            else
-              id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
-              store.put(id, Admin::CardForm.new_org_card(id, organization))
-              r.redirect "/contacts/#{id}", 303
-            end
+          first = r.params["first"].to_s.strip
+          middle = r.params["middle"].to_s.strip
+          last = r.params["last"].to_s.strip
+          # A middle name is not one of the halves that make a name: the
+          # form asks for it beside a pair it stands outside of
+          # (Admin::NamePair), and nobody is known by their middle name
+          # alone.
+          if first.empty? && last.empty?
+            dashboard(query: r.params["q"], notice: "A contact needs a name.")
           else
-            first = r.params["first"].to_s.strip
-            middle = r.params["middle"].to_s.strip
-            last = r.params["last"].to_s.strip
-            # A middle name is not one of the halves that make a name: the
-            # form asks for it beside a pair it stands outside of
-            # (Admin::NamePair), and nobody is known by their middle name
-            # alone.
-            if first.empty? && last.empty?
-              dashboard(query: r.params["q"], notice: "A contact needs a name.")
-            else
-              id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
-              store.put(id, Admin::CardForm.new_card(id, first, middle, last))
-              r.redirect "/contacts/#{id}", 303
-            end
+            id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
+            store.put(id, Admin::CardForm.new_card(id, first, middle, last))
+            r.redirect "/contacts/#{id}", 303
           end
         end
       end
diff --git a/test/fixtures/cards/company.vcf b/test/fixtures/cards/company.vcf
new file mode 100644
index 0000000..eb9d200
--- /dev/null
+++ b/test/fixtures/cards/company.vcf
@@ -0,0 +1,8 @@
+BEGIN:VCARD
+VERSION:3.0
+N:;;;;
+FN:Arbitrary Definitions
+ORG:Arbitrary Definitions;
+X-ABShowAs:COMPANY
+UID:company
+END:VCARD
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 098b4af..5eb32d0 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
             </d:report>
           </d:supported-report>
         </d:supported-report-set>
-        <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+        <cs:getctag>23</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 0ea83f5..161899b 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
             </d:report>
           </d:supported-report>
         </d:supported-report-set>
-        <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+        <cs:getctag>23</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
@@ -137,6 +137,15 @@ Content-Type: text/xml
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
   </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/company.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"9619ae633bcbe86ad4a6e177be72a8bf364dde6d5022abb26fcd0e68954c9388"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
   <d:response>
     <d:href>/dav/addressbook/emoji.vcf</d:href>
     <d:propstat>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 098b4af..5eb32d0 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
             </d:report>
           </d:supported-report>
         </d:supported-report-set>
-        <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+        <cs:getctag>23</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index c43aeeb..efe46ff 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -102,6 +102,15 @@ Content-Type: text/xml
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
   </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/company.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"9619ae633bcbe86ad4a6e177be72a8bf364dde6d5022abb26fcd0e68954c9388"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
   <d:response>
     <d:href>/dav/addressbook/emoji.vcf</d:href>
     <d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 0ea83f5..161899b 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
             </d:report>
           </d:supported-report>
         </d:supported-report-set>
-        <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+        <cs:getctag>23</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
@@ -137,6 +137,15 @@ Content-Type: text/xml
       <d:status>HTTP/1.1 200 OK</d:status>
     </d:propstat>
   </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/company.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"9619ae633bcbe86ad4a6e177be72a8bf364dde6d5022abb26fcd0e68954c9388"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
   <d:response>
     <d:href>/dav/addressbook/emoji.vcf</d:href>
     <d:propstat>
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 780bfdc..2526854 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1105,27 +1105,12 @@ class AdminContactsPagesTest < Minitest::Test
       assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
                             ':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
       assert_includes body, 'popovertargetaction="hide"'
+      # No company create rides along: organizations arrive by client
+      # and by import, and the dialog is exactly the person's.
+      refute_includes body, "new-company"
     end
   end
 
-  # The company create is its own popover, opened from the person
-  # dialog's footer link — not a toggle swapping the name fields,
-  # which made the popover lurch around its own content-sized box.
-  def test_the_dashboard_carries_the_new_company_dialog
-    get "/"
-
-    body = last_response.body
-    assert_includes body, '<button type="button" style="margin-right: auto" ' \
-                          'popovertarget="new-company">New company</button>'
-    assert_includes body, '<dialog id="new-company" popover="auto">'
-    assert_includes body, "<header>New company</header>"
-    assert_includes body, '<form id="new-company-form" action="/contacts" method="post">'
-    assert_includes body, '<input type="hidden" name="company" value="1">'
-    assert_includes body, '<label class="field">Organization' \
-                          '<input type="text" name="organization" required autofocus></label>'
-    assert_includes body, '<button type="submit" form="new-company-form" data-variant="primary">Create</button>'
-  end
-
   # A create lands through Store#put — change log, index, and all —
   # and the answer is a redirect to the record, the details page
   # where further editing belongs.
@@ -1151,35 +1136,6 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # The Company toggle's create: the card in Contacts.app's own shape
-  # for an organization, so a synced client shows it as one — the
-  # name in N's family slot, FN and ORG of the same, and
-  # X-ABShowAs:COMPANY.
-  def test_creating_an_organization_from_the_dialog
-    with_contacts({}) do |store|
-      post "/contacts", company: "1", organization: "Acme Corp"
-
-      assert_equal 303, last_response.status
-      id = last_response["Location"].delete_prefix("/contacts/")
-
-      card = store.contact(id).vcard.to_s
-      assert_includes card, "N:Acme Corp;;;;\r\n"
-      assert_includes card, "FN:Acme Corp\r\n"
-      assert_includes card, "ORG:Acme Corp\r\n"
-      assert_includes card, "X-ABShowAs:COMPANY\r\n"
-      assert_includes card, "UID:#{id}"
-    end
-  end
-
-  def test_a_nameless_organization_create_is_refused
-    with_contacts({}) do
-      post "/contacts", company: "1", organization: " "
-
-      assert_equal 200, last_response.status
-      assert_includes last_response.body, '<div role="status" data-fixed><span>A contact needs a name.</span></div>'
-    end
-  end
-
   # The middle box writes N's additional component (RFC 2426 section
   # 3.1.2) and joins the display name the other two make.
   def test_creating_a_contact_with_a_middle_name
@@ -1349,6 +1305,36 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The card a real client sends: macOS writes a company's N empty
+  # and its name in FN and ORG both (the PUT of 2026-09-24 in
+  # log/dev.log), so the organization field prefills from FN — not
+  # the family slot the older shape carried it in — and an untouched
+  # save writes the name into N while ORG keeps its own bytes.
+  def test_a_company_card_whose_name_sits_in_fn_prefills_and_saves
+    client =
+      "BEGIN:VCARD\r\nVERSION:3.0\r\nN:;;;;\r\nFN:Arbitrary Definitions\r\n" \
+        "ORG:Arbitrary Definitions;\r\nX-ABShowAs:COMPANY\r\n" \
+        "UID:client-org\r\nEND:VCARD\r\n"
+
+    with_contacts({"client-org" => client}) do |store|
+      get "/contacts/client-org/edit"
+
+      body = last_response.body
+      assert_equal 200, last_response.status
+      assert_includes body, '<span>Organization</span>' \
+                            '<input type="text" name="last" value="Arbitrary Definitions" required autofocus>'
+
+      post "/contacts/client-org", last: "Arbitrary Definitions",
+                                    etag: store.contact("client-org").etag
+
+      assert_equal 303, last_response.status
+      card = store.contact("client-org").vcard.to_s
+      assert_includes card, "N:Arbitrary Definitions;;;;\r\n"
+      assert_includes card, "FN:Arbitrary Definitions\r\n"
+      assert_includes card, "ORG:Arbitrary Definitions;\r\n"
+    end
+  end
+
   # The editor renders the contact's own rows and none a group lends:
   # a row here is a row the save may rewrite or remove, and neither is
   # this screen's to do to a value the household owns. The details