Updated
This commit is contained in:
312
ASSOC-PROFILE-SPEC.md
Normal file
312
ASSOC-PROFILE-SPEC.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Association Profile Specification
|
||||||
|
|
||||||
|
**Project:** kane-diagnostics
|
||||||
|
**Version:** 1.0
|
||||||
|
**Addon:** `assoc_profile`
|
||||||
|
**Status:** Settled — the decisions in this document govern the assoc_profile addon and the assoc_registry.json format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document specifies the `assoc_profile` addon and the `assoc_registry.json` file format. Together they provide structured, portable, operator-managed diagnostic identity fields for every association channel in the system.
|
||||||
|
|
||||||
|
The profile table in Hubzilla core has a fixed schema that cannot be extended without modifying core. This addon does not modify core. It stores association profile data in a JSON registry file and renders it on the channel profile page via Hubzilla hooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
**JSON registry, not MariaDB.**
|
||||||
|
Association profile data is written rarely and read frequently by simple key lookup. 5,000 associations at full field population is under 500KB. A flat JSON file is the correct storage for this use case — no schema, no migrations, no queries, no join complexity.
|
||||||
|
|
||||||
|
**Native PHP JSON functions only.**
|
||||||
|
Hubzilla's vendor stack contains no JSON schema or manipulation library. PHP's native `json_decode` and `json_encode` are correct and sufficient. No vendor dependency is introduced.
|
||||||
|
|
||||||
|
**Atomic writes via `LOCK_EX`.**
|
||||||
|
`file_put_contents($path, $json, LOCK_EX)` is the write mechanism. It is safe for single-server deployment and does not require any locking library.
|
||||||
|
|
||||||
|
**Two files, one slug key.**
|
||||||
|
`config.json` holds node-specific operational data — channel ID, group IDs — that differs per node.
|
||||||
|
`assoc_registry.json` holds portable diagnostic identity data — legal name, type, units, management — that is identical on every node.
|
||||||
|
The association slug is the key in both files. The addon merges by slug when it needs the full picture.
|
||||||
|
|
||||||
|
**Migration is one file copy.**
|
||||||
|
A Peer Operator installs the addon, copies `assoc_registry.json` to the addon directory, sets `www-data` ownership. No import script, no schema, no database migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/hubzilla/addon/assoc_profile/assoc_registry.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Operator-managed. Never committed to the repo. Owned by `www-data`. Path is configurable via `config.json` using the `registry_file` field — see config template below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Format
|
||||||
|
|
||||||
|
The registry is a JSON object. Each key is the association slug — lowercase, hyphens only, matching `argv(1)` in the addon routes and the key in `config.json` associations. Each value is the association's profile record.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"arbors-bgca": {
|
||||||
|
"assoc_legal_name": "Arbors of Buffalo Grove Condominium Association",
|
||||||
|
"assoc_type": "UNK",
|
||||||
|
"assoc_statute": "",
|
||||||
|
"assoc_street": "6 Oak Creek Drive",
|
||||||
|
"assoc_city": "Buffalo Grove",
|
||||||
|
"assoc_county": "Cook County",
|
||||||
|
"assoc_state": "IL",
|
||||||
|
"assoc_zip": "60089",
|
||||||
|
"assoc_placekey": "247-222@5sb-8bs-y35",
|
||||||
|
"assoc_website": "http://arborsofbuffalogrove.com",
|
||||||
|
"assoc_buildings": "6",
|
||||||
|
"assoc_units": "330",
|
||||||
|
"assoc_year_built": "1974",
|
||||||
|
"assoc_stories": "3",
|
||||||
|
"assoc_mgmt_company": "Foster Premier Inc",
|
||||||
|
"assoc_mgmt_contact": "Janet Santilli — jsantilli@fosterpremier.com",
|
||||||
|
"assoc_mgmt_certified": "UNK",
|
||||||
|
"assoc_sos_id": "",
|
||||||
|
"assoc_sos_standing": "UNK",
|
||||||
|
"assoc_declaration_recorded": "UNK",
|
||||||
|
"assoc_declaration_instrument": "",
|
||||||
|
"assoc_registered": "2026-06-15",
|
||||||
|
"assoc_updated": "2026-06-15"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Definitions
|
||||||
|
|
||||||
|
### Group 1 — Identity
|
||||||
|
|
||||||
|
| Nickname | Label | Type | Values / Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `assoc_legal_name` | Legal Name | text | Full legal name as recorded with SOS |
|
||||||
|
| `assoc_type` | Association Type | select | `Condominium` / `Master` / `CIC` / `Unincorporated` / `UNK` / `Disputed` — VS-01 |
|
||||||
|
| `assoc_statute` | Governing Statute | text | e.g. `765 ILCS 605` — populated after VS-01 verification |
|
||||||
|
| `assoc_street` | Street Address | text | Physical address of the property |
|
||||||
|
| `assoc_city` | City | text | e.g. `Buffalo Grove` |
|
||||||
|
| `assoc_county` | County | text | e.g. `Cook County` |
|
||||||
|
| `assoc_state` | State | text | e.g. `IL` |
|
||||||
|
| `assoc_zip` | ZIP Code | text | e.g. `60089` |
|
||||||
|
| `assoc_placekey` | Placekey | text | Permanent geographic anchor — e.g. `247-222@5sb-8bs-y35` |
|
||||||
|
| `assoc_website` | Association Website | text | URL of association-managed site if any |
|
||||||
|
|
||||||
|
### Group 2 — Physical Structure
|
||||||
|
|
||||||
|
| Nickname | Label | Type | Values / Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `assoc_buildings` | Number of Buildings | text | Integer or `UNK` — VS-02 |
|
||||||
|
| `assoc_units` | Number of Units | text | Integer or `UNK` — VS-02 |
|
||||||
|
| `assoc_year_built` | Year Built | text | e.g. `1974` |
|
||||||
|
| `assoc_stories` | Stories Per Building | text | e.g. `3` |
|
||||||
|
|
||||||
|
### Group 3 — Governance and Management
|
||||||
|
|
||||||
|
| Nickname | Label | Type | Values / Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `assoc_mgmt_company` | Management Company | text | e.g. `Foster Premier Inc` |
|
||||||
|
| `assoc_mgmt_contact` | Management Contact | text | Name and email of primary contact |
|
||||||
|
| `assoc_mgmt_certified` | Manager IDFPR Certified | select | `Yes` / `No` / `UNK` — VS-03 |
|
||||||
|
| `assoc_sos_id` | SOS Entity File Number | text | Illinois Secretary of State entity file number — VS-08 |
|
||||||
|
| `assoc_sos_standing` | Corporate Standing | select | `Active` / `Dissolved` / `UNK` — VS-08 |
|
||||||
|
| `assoc_declaration_recorded` | Declaration Recorded | select | `Yes` / `No` / `UNK` — VS-10 |
|
||||||
|
| `assoc_declaration_instrument` | Declaration Instrument No. | text | County Recorder document number — VS-10 |
|
||||||
|
|
||||||
|
### Group 4 — Record Metadata
|
||||||
|
|
||||||
|
| Nickname | Label | Type | Values / Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `assoc_registered` | Registered Date | text | ISO date when this association was added to the registry |
|
||||||
|
| `assoc_updated` | Last Updated | text | ISO date of most recent field update |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select Field Allowed Values
|
||||||
|
|
||||||
|
The addon validates select fields against these allowed value sets on write. Any value not in the set is rejected and the field retains its previous value.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`UNK` is the correct default for all select fields at registration. It is never null. An empty string is not a valid value for select fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Addon File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
hubzilla/addon/assoc_profile/
|
||||||
|
assoc_profile.php — entry point: hooks, load/unload, content router
|
||||||
|
assoc_profile.apd — app descriptor
|
||||||
|
mod_assoc_profile.pdl — PDL layout
|
||||||
|
config.json.template — registry_file path and other config
|
||||||
|
assoc_registry.json.template — empty registry with one example entry
|
||||||
|
view/
|
||||||
|
css/
|
||||||
|
assoc_profile.css — profile display styles
|
||||||
|
js/
|
||||||
|
assoc_profile.js — minimal behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
No `Widget/` directory — this addon does not render a sidebar Widget. The institutional directory Widget belongs to `vs01`, `dsc01`, and `scn01`. This addon renders profile fields only.
|
||||||
|
|
||||||
|
No `vital-signs/` directory — this addon does not render VS forms.
|
||||||
|
|
||||||
|
No `contracts/` directory — this addon does not POST to the orchestrator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHP Function Structure
|
||||||
|
|
||||||
|
### Registry I/O
|
||||||
|
|
||||||
|
```php
|
||||||
|
function assoc_load_registry() {
|
||||||
|
$config = assoc_load_config();
|
||||||
|
$path = $config['registry_file']
|
||||||
|
?? 'addon/assoc_profile/assoc_registry.json';
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
logger('assoc_profile: registry file not found: ' . $path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
logger('assoc_profile: registry file malformed: ' . $path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_write_registry($data) {
|
||||||
|
$config = assoc_load_config();
|
||||||
|
$path = $config['registry_file']
|
||||||
|
?? 'addon/assoc_profile/assoc_registry.json';
|
||||||
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
logger('assoc_profile: failed to encode registry');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$result = file_put_contents($path, $json, LOCK_EX);
|
||||||
|
if ($result === false) {
|
||||||
|
logger('assoc_profile: failed to write registry: ' . $path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_get($slug) {
|
||||||
|
$registry = assoc_load_registry();
|
||||||
|
return $registry[$slug] ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```php
|
||||||
|
function assoc_validate_select($field, $value) {
|
||||||
|
$allowed = [
|
||||||
|
'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'],
|
||||||
|
];
|
||||||
|
if (!isset($allowed[$field])) return true; // text fields always valid
|
||||||
|
return in_array($value, $allowed[$field], true);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
The addon hooks into two Hubzilla hooks:
|
||||||
|
|
||||||
|
**`profile_edit`** — renders the assoc_profile fields in the profile edit form when the channel being edited has a registry entry. Operator-only — the form only renders if `local_channel()` is the channel owner.
|
||||||
|
|
||||||
|
**`profile_view`** — renders the assoc_profile fields in the profile view when the channel being viewed has a registry entry. Public — any visitor sees the fields.
|
||||||
|
|
||||||
|
The hook callbacks read the registry by the channel's `channel_address` slug, extract the slug from the address, and render accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## config.json.template
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hubzilla Hook Registration
|
||||||
|
|
||||||
|
```php
|
||||||
|
function assoc_profile_load() {
|
||||||
|
register_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook');
|
||||||
|
register_hook('profile_view', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_profile_unload() {
|
||||||
|
unregister_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook');
|
||||||
|
unregister_hook('profile_view', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profile Display Format
|
||||||
|
|
||||||
|
When rendered on the channel profile page, fields are grouped under a section heading **"Association Diagnostic Profile"** and displayed in the three groups defined above. Select fields show their human-readable value. Empty text fields show `—`. `UNK` select values display as `Unknown — not yet verified`.
|
||||||
|
|
||||||
|
The `pdesc` field on the Hubzilla profile (short description) is set by the operator manually to the structured summary format:
|
||||||
|
|
||||||
|
```
|
||||||
|
{assoc_type} | {assoc_buildings} bldgs | {assoc_units} units | {assoc_county}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
UNK | 6 bldgs | 330 units | Cook County
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the one field that uses the native Hubzilla profile table and is not stored in the registry. It is the at-a-glance identity visible in directory listings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Procedure
|
||||||
|
|
||||||
|
**On the receiving Peer Operator node, before importing the channel:**
|
||||||
|
|
||||||
|
1. Install the `assoc_profile` addon
|
||||||
|
2. Copy `assoc_registry.json` from the source node to `addon/assoc_profile/` on the target node
|
||||||
|
3. Set ownership: `chown www-data:www-data /var/www/hubzilla/addon/assoc_profile/assoc_registry.json`
|
||||||
|
4. Create and populate `config.json` with the `registry_file` path
|
||||||
|
5. Import the channel via Hubzilla's standard channel import
|
||||||
|
|
||||||
|
The registry data is now present on the target node before the channel arrives. The channel profile page will render the association fields immediately after import.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What This Addon Does Not Do
|
||||||
|
|
||||||
|
- It does not create a database table
|
||||||
|
- It does not POST to the orchestrator
|
||||||
|
- It does not render a sidebar Widget
|
||||||
|
- It does not manage privacy groups
|
||||||
|
- It does not handle VS form submissions
|
||||||
|
- It does not register associations — registration is adding a slug entry to `assoc_registry.json` and a corresponding entry to `config.json`
|
||||||
Reference in New Issue
Block a user