Files
kane-diagnostics/ASSOCIATION-CHANNEL-MODEL.md
2026-06-06 02:01:02 -04:00

11 KiB

Association Channel Model

Project: kane-diagnostics
Version: 1.0
Status: Settled — the decisions in this document govern all three addons and all future addons in this project


The Core Decision

Each association in the diagnostic system is a first-class Hubzilla entity — a channel with its own identity, its own privacy groups, and its own membership. Every diagnostic submission, every VS record, every scenario account, and every DSC entry is attached to an association channel, not to the operator's channel.

This is not a technical convenience. It is the architectural expression of the project's civic purpose. The diagnostic record belongs to the association — to its geography, its homeowners, and its governance history — not to the platform operator. The channel model makes that ownership explicit and portable.


Why Hubzilla Channels

A Hubzilla channel is a portable, federated identity. It carries:

  • Its own cryptographic keys
  • Its own connections and connection permissions
  • Its own privacy groups and group memberships
  • Its own content and activity history
  • Its own ACL settings

Verified from the host: the Hubzilla channel export includes group, group_member, and channel_default_group. Privacy groups and their memberships are exported with the channel and survive node migration intact.

This means an association channel created on diagnostics.kane-il.us can be cloned to a RON-operated node — Cook County, Orange County CA, or any aligned Civic Infrastructure host — and the channel identity, its privacy groups, and its member assignments all travel with it. The diagnostic record is not tied to any single node.


Horizontal Scaling

There are thousands of HOA associations in Illinois. Each association channel is independent. Demand is distributed naturally across nodes — a Kane County association lives on the Kane County node, a Cook County association lives on the Cook County node, a California association lives on whatever RON node serves that geography.

No central node needs to hold all associations. No migration required when demand grows — the Peer Operator slot in the institutional directory is the operational expression of this design. The Peer Operator is the node that receives cloned channels when capacity or geography requires it.


Channel Structure Per Association

Each association channel is created by the operator. The operator is the channel owner. The channel carries:

Identity fields:

  • Channel name: the association's legal name
  • Channel address: [association-slug]@diagnostics.kane-il.us
  • Location: street address of the association (Kane County address for Kane associations)
  • Description: association type (VS-01 classification), unit count (VS-02), and primary statute

Privacy Groups — three tiers:

Group name Role Access
public Anyone Read-only — VS content, DSC categories, scenario carousel
sase_participant SASE-verified homeowner Submit VS records, submit scenario accounts
corpus_builder Promoted participant or operator Review TMP, promote to corpus, submit professional records

The group names are conventions, not hardcoded values. The addon reads group IDs from config.json per association — not a single global value.

Default group: public — unauthenticated visitors see the association's public record without joining.


config.json Per Association

The current config.json.template carries a single corpus_builder_group_id. This is replaced by a per-association configuration structure.

The revised config.json.template:

{
  "_note": "Copy to config.json. Do not commit config.json.",
  "receiver_url": "REPLACE — orchestrator receiver endpoint",
  "node_token": "REPLACE — shared secret for node-to-orchestrator auth",
  "listings_file": "REPLACE — absolute path to listings.json on the host",
  "directory_default_tab": "core",
  "associations": {
    "REPLACE-CHANNEL-ID": {
      "name": "REPLACE — association legal name",
      "address": "REPLACE — association street address",
      "channel_id": "REPLACE — Hubzilla channel ID (integer)",
      "channel_address": "REPLACE — channel@node address",
      "groups": {
        "public": 0,
        "sase_participant": 0,
        "corpus_builder": 0
      }
    }
  }
}

Multiple associations can be registered on a single node — each as a keyed entry in the associations object. The addon identifies the current association from the URL path: /vs01/[association-slug].


Access State Function — Revised

The current vs01_access_state() function reads a single global group ID. The revised function accepts an association identifier and reads the per-association group configuration.

function vs01_access_state($association_key) {
    $config = vs01_load_config();
    $assoc  = $config['associations'][$association_key] ?? null;

    if (!$assoc) return 'public';

    $channel_id = local_channel();
    if (!$channel_id) return 'public';

    // Operator check — channel owner of the association channel
    if ($channel_id === intval($assoc['channel_id'])) return 'operator';

    $observer = get_observer_hash();
    $groups   = $assoc['groups'] ?? [];

    // Corpus Builder — highest participant tier
    $cb_gid = intval($groups['corpus_builder'] ?? 0);
    if ($cb_gid && in_array($observer, group_get_members_xchan($cb_gid))) {
        return 'participant'; // corpus_builder maps to participant access state
    }

    // SASE Participant
    $sase_gid = intval($groups['sase_participant'] ?? 0);
    if ($sase_gid && in_array($observer, group_get_members_xchan($sase_gid))) {
        return 'participant';
    }

    return 'public';
}

The professional access state is determined separately — by the presence of an active listing entry in listings.json matching the current observer's xchan hash. This is not a Hubzilla group — it is an operator-managed listing record.

function vs01_is_professional($observer_hash) {
    $listings = vs01_load_listings();
    foreach (['tier1', 'tier2'] as $tier) {
        foreach ($listings[$tier] ?? [] as $entry) {
            if (($entry['xchan'] ?? '') === $observer_hash
                && ($entry['status'] ?? '') === 'active') {
                return true;
            }
        }
    }
    return false;
}

The listings.json schema gains an xchan field per Tier-I and Tier-II entry. The operator records the professional's xchan hash when approving the listing. This is the connection between the institutional directory and the access control system.


URL Routing

The current skeleton routes on /vs01 with no association context. The revised routing adds the association slug as the first path segment:

/vs01                          — index: list of registered associations
/vs01/[association-slug]       — association landing page, VS navigation
/vs01/[association-slug]/VS-01 — VS-01 form for this association
/vs01/[association-slug]/VS-02 — VS-02 form for this association
...
/vs01/[association-slug]/manage — operator management interface

The addon reads argv(1) for the association slug and argv(2) for the VS code. Both are validated against the config before any content is rendered.

function vs01_content() {
    $association_slug = argv(1) ?? '';
    $vs_code          = strtoupper(argv(2) ?? '');

    if (!$association_slug) {
        return vs01_render_association_index();
    }

    $config = vs01_load_config();
    if (!isset($config['associations'][$association_slug])) {
        return vs01_render_not_found();
    }

    $access = vs01_access_state($association_slug);

    if ($vs_code && preg_match('/^VS-\d{2}$/', $vs_code)) {
        return vs01_render_vs_form($association_slug, $vs_code, $access);
    }

    return vs01_render_association_landing($association_slug, $access);
}

Operator Workflow — Creating an Association Channel

When a new association is registered in the diagnostic system, the operator:

  1. Creates a new Hubzilla channel for the association on the node
  2. Creates three privacy groups on that channel: sase_participant, corpus_builder, and notes the public group ID (group 0 or the channel's default)
  3. Notes the channel ID (integer) and channel address
  4. Adds the association entry to config.json on the host with the channel ID, address, and group IDs
  5. Pulls from Gitea and copies the updated config to the addon directory

No code change is required to add a new association. The JSON controls the registration. This is the JSON-over-PHP discipline applied to the association model.


Migration Workflow — Cloning to a RON Node

When an association channel is migrated to a Peer Operator node:

  1. The operator exports the association channel from the current node (Hubzilla channel export)
  2. The Peer Operator imports the channel to their node
  3. The export includes: channel identity, connections, privacy groups, group memberships, and content
  4. The Peer Operator adds the association entry to their node's config.json with the new channel ID assigned on import
  5. The spool receiver URL in config.json points to the Peer Operator's orchestrator

The diagnostic record — the VS submissions, the scenario accounts, the DSC entries — lives in the orchestrator and IPFS, not in the Hubzilla channel. The channel migration moves the identity and access control. The record itself is independently preserved.


What Changes in the Skeleton

The current skeleton vs01.php has:

  • A single corpus_builder_group_id in config
  • A single vs01_access_state() with no association context
  • A single route at /vs01

The revised vs01.php needs:

  • Per-association config structure
  • vs01_access_state($association_slug) with group lookup per association
  • vs01_is_professional($observer_hash) with listings lookup
  • URL routing on argv(1) and argv(2)
  • Association index render function
  • Association landing render function
  • VS form render function — loads schema, renders form per VS-RENDERER-SPEC.md

The config.json.template is revised to the per-association structure defined above.

The listings.json schema gains an xchan field per Tier-I and Tier-II entry — to be added in the next revision of ADDON-SKELETON-SPEC.md.


What Does Not Change

  • The PDL — aside, content, right_aside structure is unchanged
  • The Widget — four-tab institutional directory is unchanged
  • The right PDL — notifications and newmember are unchanged
  • The spool envelope — gains association_channel_id in place of association_address
  • The orchestrator pattern — the addon POSTs to the orchestrator, nothing stored on the Hubzilla node
  • The coding constraints — 500-line limit, no inline styles or scripts, no hardcoded values