diff --git a/hubzilla/addon/assoc_profile/assoc_profile.apd b/hubzilla/addon/assoc_profile/assoc_profile.apd
new file mode 100644
index 0000000..8db10df
--- /dev/null
+++ b/hubzilla/addon/assoc_profile/assoc_profile.apd
@@ -0,0 +1,7 @@
+version: 2
+url: $baseurl/assoc_profile
+requires: local_channel
+name: Association Profile
+photo: icon:building
+categories: Civic Diagnostics
+desc: Structured diagnostic identity fields for HOA association channels.
diff --git a/hubzilla/addon/assoc_profile/assoc_profile.php b/hubzilla/addon/assoc_profile/assoc_profile.php
new file mode 100644
index 0000000..ade6295
--- /dev/null
+++ b/hubzilla/addon/assoc_profile/assoc_profile.php
@@ -0,0 +1,378 @@
+ ['Condominium','Master','CIC','Unincorporated','UNK','Disputed'],
+ 'assoc_mgmt_certified' => ['Yes','No','UNK'],
+ 'assoc_sos_standing' => ['Active','Dissolved','UNK'],
+ 'assoc_declaration_recorded' => ['Yes','No','UNK'],
+ ];
+}
+
+function assoc_validate_select($field, $value) {
+ $allowed = assoc_allowed_values();
+ if (!isset($allowed[$field])) return true;
+ return in_array($value, $allowed[$field], true);
+}
+
+function assoc_field_names() {
+ return [
+ 'assoc_legal_name', 'assoc_type', 'assoc_statute',
+ 'assoc_street', 'assoc_city', 'assoc_county', 'assoc_state',
+ 'assoc_zip', 'assoc_placekey', 'assoc_website',
+ 'assoc_buildings', 'assoc_units', 'assoc_year_built', 'assoc_stories',
+ 'assoc_mgmt_company', 'assoc_mgmt_contact', 'assoc_mgmt_certified',
+ 'assoc_sos_id', 'assoc_sos_standing',
+ 'assoc_declaration_recorded', 'assoc_declaration_instrument',
+ 'assoc_registered', 'assoc_updated',
+ ];
+}
+
+// ----------------------------------------------------------------------------
+// PROFILE EDIT HOOK
+// ----------------------------------------------------------------------------
+
+function assoc_profile_edit_hook(&$b) {
+ if (!local_channel()) return;
+
+ $channel = App::get_channel();
+ $slug = assoc_slug_from_address($channel['channel_address'] ?? '');
+ if (!$slug) return;
+
+ $entry = assoc_get($slug);
+ if (!$entry) return;
+
+ // Only channel owner may edit
+ if (local_channel() !== intval($channel['channel_id'])) return;
+
+ if (function_exists('head_add_css')) {
+ head_add_css('/addon/assoc_profile/view/css/assoc_profile.css');
+ }
+
+ $b['html'] .= assoc_render_edit_form($slug, $entry);
+}
+
+// ----------------------------------------------------------------------------
+// PROFILE VIEW HOOK
+// ----------------------------------------------------------------------------
+
+function assoc_profile_view_hook(&$b) {
+ $profile = $b['profile'] ?? [];
+ $uid = intval($profile['uid'] ?? 0);
+ if (!$uid) return;
+
+ $r = q("SELECT channel_address FROM channel WHERE channel_id = %d LIMIT 1",
+ intval($uid));
+ if (!$r) return;
+
+ $slug = assoc_slug_from_address($r[0]['channel_address']);
+ $entry = assoc_get($slug);
+ if (!$entry) return;
+
+ if (function_exists('head_add_css')) {
+ head_add_css('/addon/assoc_profile/view/css/assoc_profile.css');
+ }
+
+ $b['html'] .= assoc_render_view($entry);
+}
+
+// ----------------------------------------------------------------------------
+// RENDER — VIEW
+// ----------------------------------------------------------------------------
+
+function assoc_render_view($entry) {
+ $labels = assoc_field_labels();
+ $groups = assoc_field_groups();
+
+ $out = '
';
+ $out .= '
Association Diagnostic Profile
';
+
+ foreach ($groups as $group_label => $fields) {
+ $out .= '
';
+ $out .= '
' . assoc_h($group_label) . '
';
+ foreach ($fields as $field) {
+ $val = $entry[$field] ?? '';
+ if ($val === '') $val = '—';
+ if (in_array($val, ['UNK'])) $val = 'Unknown — not yet verified';
+ $out .= '
';
+ $out .= '' . assoc_h($labels[$field] ?? $field) . '';
+ $out .= '' . assoc_h($val) . '';
+ $out .= '
';
+ }
+ $out .= '
';
+ }
+
+ $out .= '
';
+ return $out;
+}
+
+// ----------------------------------------------------------------------------
+// RENDER — EDIT FORM
+// ----------------------------------------------------------------------------
+
+function assoc_render_edit_form($slug, $entry) {
+ $labels = assoc_field_labels();
+ $groups = assoc_field_groups();
+ $allowed = assoc_allowed_values();
+
+ $out = '';
+ $out .= '
Association Diagnostic Profile
';
+ $out .= '
';
+ return $out;
+}
+
+function assoc_render_text($field, $label, $value) {
+ $id = assoc_h($field);
+ return '
+
+
+
';
+}
+
+function assoc_render_select($field, $label, $value, $options) {
+ $id = assoc_h($field);
+ $out = '';
+ $out .= '';
+ $out .= '
';
+ return $out;
+}
+
+// ----------------------------------------------------------------------------
+// SAVE HANDLER
+// ----------------------------------------------------------------------------
+
+function assoc_profile_content() {
+ if (argv(1) !== 'save') return '';
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') return '';
+ if (!local_channel()) return '';
+ if (!assoc_verify_csrf()) {
+ return 'Invalid form token.
';
+ }
+
+ $slug = substr(strip_tags($_POST['assoc_slug'] ?? ''), 0, 128);
+ if (!$slug) return 'Missing association slug.
';
+
+ $registry = assoc_load_registry();
+ if (!isset($registry[$slug])) {
+ return 'Association not found in registry.
';
+ }
+
+ // Verify caller is channel owner for this slug
+ $channel = App::get_channel();
+ if (assoc_slug_from_address($channel['channel_address'] ?? '') !== $slug) {
+ return 'Not authorized.
';
+ }
+
+ $allowed = assoc_allowed_values();
+ $fields = assoc_field_names();
+
+ foreach ($fields as $field) {
+ if (in_array($field, ['assoc_registered', 'assoc_updated'])) continue;
+ if (!isset($_POST[$field])) continue;
+ $val = substr(strip_tags((string) $_POST[$field]), 0, 512);
+ if (isset($allowed[$field]) && !assoc_validate_select($field, $val)) continue;
+ $registry[$slug][$field] = $val;
+ }
+
+ $registry[$slug]['assoc_updated'] = date('Y-m-d');
+
+ if (assoc_write_registry($registry)) {
+ goaway(z_root() . '/profile/' . $slug);
+ }
+
+ return 'Failed to save. Check server logs.
';
+}
+
+// ----------------------------------------------------------------------------
+// FIELD METADATA
+// ----------------------------------------------------------------------------
+
+function assoc_field_labels() {
+ return [
+ 'assoc_legal_name' => 'Legal Name',
+ 'assoc_type' => 'Association Type',
+ 'assoc_statute' => 'Governing Statute',
+ 'assoc_street' => 'Street Address',
+ 'assoc_city' => 'City',
+ 'assoc_county' => 'County',
+ 'assoc_state' => 'State',
+ 'assoc_zip' => 'ZIP Code',
+ 'assoc_placekey' => 'Placekey',
+ 'assoc_website' => 'Association Website',
+ 'assoc_buildings' => 'Number of Buildings',
+ 'assoc_units' => 'Number of Units',
+ 'assoc_year_built' => 'Year Built',
+ 'assoc_stories' => 'Stories Per Building',
+ 'assoc_mgmt_company' => 'Management Company',
+ 'assoc_mgmt_contact' => 'Management Contact',
+ 'assoc_mgmt_certified' => 'Manager IDFPR Certified',
+ 'assoc_sos_id' => 'SOS Entity File Number',
+ 'assoc_sos_standing' => 'Corporate Standing',
+ 'assoc_declaration_recorded' => 'Declaration Recorded',
+ 'assoc_declaration_instrument' => 'Declaration Instrument No.',
+ 'assoc_registered' => 'Registered Date',
+ 'assoc_updated' => 'Last Updated',
+ ];
+}
+
+function assoc_field_groups() {
+ return [
+ 'Identity' => [
+ 'assoc_legal_name','assoc_type','assoc_statute',
+ 'assoc_street','assoc_city','assoc_county',
+ 'assoc_state','assoc_zip','assoc_placekey','assoc_website',
+ ],
+ 'Physical Structure' => [
+ 'assoc_buildings','assoc_units','assoc_year_built','assoc_stories',
+ ],
+ 'Governance and Management' => [
+ 'assoc_mgmt_company','assoc_mgmt_contact','assoc_mgmt_certified',
+ 'assoc_sos_id','assoc_sos_standing',
+ 'assoc_declaration_recorded','assoc_declaration_instrument',
+ ],
+ 'Record' => [
+ 'assoc_registered','assoc_updated',
+ ],
+ ];
+}
+
+// ----------------------------------------------------------------------------
+// CSRF
+// ----------------------------------------------------------------------------
+
+function assoc_csrf_token() {
+ if (empty($_SESSION['assoc_profile_csrf'])) {
+ $_SESSION['assoc_profile_csrf'] = bin2hex(random_bytes(16));
+ }
+ return '';
+}
+
+function assoc_verify_csrf() {
+ return isset($_POST['assoc_profile_csrf'], $_SESSION['assoc_profile_csrf'])
+ && hash_equals($_SESSION['assoc_profile_csrf'], $_POST['assoc_profile_csrf']);
+}
diff --git a/hubzilla/addon/assoc_profile/assoc_registry.json.template b/hubzilla/addon/assoc_profile/assoc_registry.json.template
new file mode 100644
index 0000000..367a652
--- /dev/null
+++ b/hubzilla/addon/assoc_profile/assoc_registry.json.template
@@ -0,0 +1,37 @@
+{
+ "_note": "Copy to assoc_registry.json on the host. Do not commit assoc_registry.json — it contains operator-managed association data. Owned by www-data. One entry per association, keyed by slug.",
+ "_version": "1.0",
+ "_slug_format": "Lowercase, hyphens only. Must match the key in config.json associations and argv(1) in addon routes.",
+ "_select_values": {
+ "assoc_type": ["Condominium", "Master", "CIC", "Unincorporated", "UNK", "Disputed"],
+ "assoc_mgmt_certified": ["Yes", "No", "UNK"],
+ "assoc_sos_standing": ["Active", "Dissolved", "UNK"],
+ "assoc_declaration_recorded": ["Yes", "No", "UNK"]
+ },
+ "REPLACE-SLUG": {
+ "_note": "Replace REPLACE-SLUG with the association slug, e.g. arbors-bgca",
+ "assoc_legal_name": "REPLACE — full legal name as recorded with Illinois Secretary of State",
+ "assoc_type": "UNK",
+ "assoc_statute": "",
+ "assoc_street": "REPLACE — street address of the property",
+ "assoc_city": "REPLACE — city",
+ "assoc_county": "REPLACE — county, e.g. Cook County",
+ "assoc_state": "IL",
+ "assoc_zip": "REPLACE — ZIP code",
+ "assoc_placekey": "REPLACE — Placekey, e.g. 247-222@5sb-8bs-y35",
+ "assoc_website": "",
+ "assoc_buildings": "UNK",
+ "assoc_units": "UNK",
+ "assoc_year_built": "",
+ "assoc_stories": "",
+ "assoc_mgmt_company": "",
+ "assoc_mgmt_contact": "",
+ "assoc_mgmt_certified": "UNK",
+ "assoc_sos_id": "",
+ "assoc_sos_standing": "UNK",
+ "assoc_declaration_recorded": "UNK",
+ "assoc_declaration_instrument": "",
+ "assoc_registered": "REPLACE — ISO date, e.g. 2026-06-15",
+ "assoc_updated": "REPLACE — ISO date, e.g. 2026-06-15"
+ }
+}
diff --git a/hubzilla/addon/assoc_profile/config.json.template b/hubzilla/addon/assoc_profile/config.json.template
new file mode 100644
index 0000000..9d400bb
--- /dev/null
+++ b/hubzilla/addon/assoc_profile/config.json.template
@@ -0,0 +1,4 @@
+{
+ "_note": "Copy to config.json. Do not commit config.json.",
+ "registry_file": "REPLACE — absolute path to assoc_registry.json on the host, e.g. /var/www/hubzilla/addon/assoc_profile/assoc_registry.json"
+}
diff --git a/hubzilla/addon/assoc_profile/mod_assoc_profile.pdl b/hubzilla/addon/assoc_profile/mod_assoc_profile.pdl
new file mode 100644
index 0000000..4bf8ec7
--- /dev/null
+++ b/hubzilla/addon/assoc_profile/mod_assoc_profile.pdl
@@ -0,0 +1,5 @@
+[template]default[/template]
+
+[region=content]
+$content
+[/region]