diff --git a/AGENTS.md b/AGENTS.md
index 066e78c386..db37359ab7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -146,7 +146,7 @@ This codebase (Rails 8.1)
| `Communicable` | Gives a record the `notifications` association, nested attributes, a default `communications_scope`, and a `before_validation` addressing a hand-logged communication to the record's `communications_email`. Included by every model whose form renders `shared/_comments_and_communications` |
| `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page. Anonymity is a one-way latch: the profile or the record can set it, neither can strip it from the other. `credits_to_org` flips the unattributed label to "AWBW Staff"; `credits_creator` (idea models, workshop logs) credits the submitting account's person when no author is named |
| `Featureable` | `featured`, `publicly_featured` scopes |
-| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). `filemaker_archived_in` names the `Fm*` archive table(s) a model's codes point into (`.filemaker_archives`), and `.with_filemaker_code(code)` finds records carrying a code. Nothing should compare the whole column against a single code — `FilemakerHelper#filemaker_code_links` renders one archive link per code, and `fm_archives#show` links back to the portal records carrying its code |
+| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization, Affiliation): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). `filemaker_archived_in` names the `Fm*` archive table(s) a model's codes point into (`.filemaker_archives`), and `.with_filemaker_code(code)` finds records carrying a code. Nothing should compare the whole column against a single code — `FilemakerHelper#filemaker_code_links` renders one archive link per code, and `fm_archives#show` links back to the portal records carrying its code |
| `Mentioner` | ActionText @mention extraction and grouping |
| `NameFilterable` | Name-based filtering |
| `PayChargeExtensions` | Included into `Pay::Charge` (via `config/initializers/pay.rb`): turns a Stripe charge into an AWBW `Payment` + `Allocation`, resolving the membership invoice it pays |
diff --git a/app/controllers/fm_archives_controller.rb b/app/controllers/fm_archives_controller.rb
index 77b4e7aa18..48891710c9 100644
--- a/app/controllers/fm_archives_controller.rb
+++ b/app/controllers/fm_archives_controller.rb
@@ -63,7 +63,7 @@ class FmArchivesController < ApplicationController
{ label: "Other", icon: "📎", tables: %w[notes] }
]
- PORTAL_MODELS = [ Person, Organization ].freeze
+ PORTAL_MODELS = [ Person, Organization, Affiliation ].freeze
def index
authorize! :fm_archive
diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb
index dbcdea1fea..c646c1f070 100644
--- a/app/helpers/filemaker_helper.rb
+++ b/app/helpers/filemaker_helper.rb
@@ -33,6 +33,7 @@ def filemaker_portal_link(record)
text, path = case record
when Person then [ record.full_name, person_path(record) ]
when Organization then [ record.name, organization_path(record) ]
+ when Affiliation then [ "#{record.person&.full_name} at #{record.organization&.name}", edit_affiliation_path(record) ]
end
link_to text, path, class: button_classes(:primary_outline, size: :sm)
diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb
index fffee4aa27..0a9a4a1168 100644
--- a/app/models/affiliation.rb
+++ b/app/models/affiliation.rb
@@ -1,5 +1,8 @@
class Affiliation < ApplicationRecord
include Communicable
+ include FilemakerCoded
+ filemaker_archived_in FmProject, FmPersonnel
+
# Standing title given to the "facilitator affiliation" we create on registration
# and org linking. Matches the `facilitators` scope / `facilitator?` predicate
# (both treat exactly "Facilitator" as canonical).
diff --git a/app/views/affiliations/edit.html.erb b/app/views/affiliations/edit.html.erb
index 7f59588960..670a14f856 100644
--- a/app/views/affiliations/edit.html.erb
+++ b/app/views/affiliations/edit.html.erb
@@ -77,6 +77,7 @@
<%= f.input :filemaker_code,
label: "FileMaker code",
+ hint: filemaker_code_hint(@affiliation),
label_html: { class: "block text-xs font-medium text-gray-700 mb-1" },
input_html: { class: "w-full rounded-md border-gray-300 bg-white shadow-sm focus:border-blue-500 focus:ring-blue-200 text-sm" } %>
diff --git a/config/features.yml b/config/features.yml
index 6400d70d81..f1cf79f513 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -4250,3 +4250,14 @@
- "Separate several codes with commas. Spaces, tabs, and line breaks are ignored, and the list saves sorted."
- "An organization's edit page also links the FileMaker organization its project belongs to."
- "An organization's code can be a FileMaker project ID (like P2775) or organization ID (like ORG07206). Type the whole code, letters included."
+
+- name: "Open an affiliation's FileMaker record"
+ area: people
+ display_status: admin_facing
+ released_on: 2026-10-12
+ pr_number: 2649
+ summary: >-
+ An affiliation's edit page now links its FileMaker code to the archive, and
+ the archive record links back to the affiliation.
+ pro_tips:
+ - "An affiliation's code is usually its program's FileMaker project ID (like P2775), and occasionally a personnel ID (like PS9999)."
diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md
index 55643d059c..76e70c197e 100644
--- a/docs/adr/0005-filemaker-code-archive-mapping.md
+++ b/docs/adr/0005-filemaker-code-archive-mapping.md
@@ -25,7 +25,7 @@ drive the archive pages' links.
|---|---|---|---|
| `people.filemaker_code` | `fm_rolodexes` | `ID`, e.g. `00006` | `filemaker_code_links` on person edit + admin details |
| `organizations.filemaker_code` | `fm_projects`, then `fm_organizations` | `ProjectID`, e.g. `P2775`; `OrgID`, e.g. `ORG07206` | `filemaker_code_links` on org form; `filemaker_project_links` / `filemaker_organization_links` on org edit |
-| `affiliations.filemaker_code` | `fm_projects`, then `fm_personnels` | `ProjectID`, e.g. `P2775`; rarely `PrsnlRecID`, e.g. `PS9999` | no UI link yet |
+| `affiliations.filemaker_code` | `fm_projects`, then `fm_personnels` | `ProjectID`, e.g. `P2775`; rarely `PrsnlRecID`, e.g. `PS9999` | `filemaker_code_links` on affiliation edit |
| `payments.filemaker_code` | `fm_payments` | `RecordID`, e.g. `D29186` | dev seed (no UI link yet) |
| `workshops.filemaker_code` | `fm_workshops` | `WorkshopID` | dev seed (no UI link yet) |
| `resources.filemaker_code` | `fm_quotations` | `RecordID`, e.g. `Q10215` | no UI link yet |
@@ -173,6 +173,6 @@ Production, 2026-10-12: 84 records (41 organizations, 16 affiliations, 14 worksh
## Consequences
-A UI link for affiliations, payments, workshops, or resources includes `FilemakerCoded` on the
+A UI link for payments, workshops, or resources includes `FilemakerCoded` on the
model and declares the archive table above (e.g. `filemaker_archived_in FmPersonnel`);
`filemaker_code_links` reads it from there.
diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb
index 4485c1a52b..777d2cf852 100644
--- a/spec/helpers/filemaker_helper_spec.rb
+++ b/spec/helpers/filemaker_helper_spec.rb
@@ -66,6 +66,22 @@
expect(helper.filemaker_code_links(person)).to be_nil
end
end
+
+ context "with a record archived in more than one table" do
+ let(:affiliation) { build(:affiliation, filemaker_code: "P2775, PS9999") }
+
+ before do
+ FmProject.create!(fm_id: "P2775")
+ FmPersonnel.create!(fm_id: "PS9999")
+ end
+
+ it "links each code to whichever table holds it" do
+ html = helper.filemaker_code_links(affiliation)
+
+ expect(html).to include(fm_archive_path("P2775", table: "projects"))
+ expect(html).to include(fm_archive_path("PS9999", table: "personnels"))
+ end
+ end
end
describe "#filemaker_code_hint" do
diff --git a/spec/models/concerns/filemaker_coded_spec.rb b/spec/models/concerns/filemaker_coded_spec.rb
index 6098393b91..4c1d7bd465 100644
--- a/spec/models/concerns/filemaker_coded_spec.rb
+++ b/spec/models/concerns/filemaker_coded_spec.rb
@@ -65,6 +65,7 @@
it "names the archive tables each coded model points into" do
expect(Person.filemaker_archives).to eq([ FmRolodex ])
expect(Organization.filemaker_archives).to eq([ FmProject, FmOrganization ])
+ expect(Affiliation.filemaker_archives).to eq([ FmProject, FmPersonnel ])
end
end
end
diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb
index e4e16165e5..128c65b9fd 100644
--- a/spec/requests/fm_archives_spec.rb
+++ b/spec/requests/fm_archives_spec.rb
@@ -65,6 +65,25 @@
expect(response.body.scan(%r{href="/organizations/\d+"}).size).to eq(2)
end
+ it "links an archived project back to the affiliations carrying its code" do
+ FmProject.create!(fm_id: "P2775")
+ affiliation = create(:affiliation, filemaker_code: "P2775")
+
+ get fm_archive_path("P2775", table: "projects")
+
+ expect(response.body).to include(edit_affiliation_path(affiliation))
+ end
+
+ it "links an archived personnel row back to its portal affiliation" do
+ FmPersonnel.create!(fm_id: "PS9999")
+ affiliation = create(:affiliation, filemaker_code: "PS9999")
+
+ get fm_archive_path("PS9999", table: "personnels")
+
+ expect(response.body).to include(edit_affiliation_path(affiliation))
+ expect(response.body).to include(ERB::Util.html_escape("#{affiliation.person.full_name} at #{affiliation.organization.name}"))
+ end
+
it "omits the portal section when nothing carries the code" do
FmRolodex.create!(fm_id: "00006")