Initial push

This commit is contained in:
2026-06-13 08:09:39 -04:00
parent d2d2a4d596
commit b8750e50c5
5 changed files with 587 additions and 21 deletions

View File

@@ -0,0 +1,191 @@
# DSC-01 Renderer Spec — Diagnostic Surface Categories
**Version:** 1.0
**Companion to:** VS-RENDERER-SPEC.md (Vital Signs)
**Source taxonomy:** DSC-development-map.md
---
## 1. Purpose
dsc01 presents the ten Active Diagnostic Surface Categories (DSC-01 through
DSC-10) as a **homeowner-applied checklist** attached to a case record. It is
architecturally distinct from vs01:
| | vs01 (Vital Signs) | dsc01 (Categories) |
|---|---|---|
| Cardinality | One record per association | Many records per association (one per case) |
| Authored by | Operator defines schema; participants/professionals/operator fill perspectives | Operator defines taxonomy only; homeowner selects |
| Record shape | Per-VS-code form submission | Single record: multi-select DSC codes + narrative |
| Mutability | Public-record perspective may be immutable after submit | Each case record is its own append-only entry |
| Storage | vs01 spool → orchestrator | dsc01 spool → orchestrator (separate receiver/records file) |
The ten DSC schema files under `diagnostic-categories/` are **read-only
reference data** to the renderer. Homeowners never create, edit, or remove
DSC codes, fields, or categories — that is operator-only, done by editing
the schema JSON files and pushing through the standard tarball workflow.
---
## 2. Schema file shape
Each `diagnostic-categories/DSC-0X-*.json` contains a single `_meta` block:
```json
{
"_meta": {
"code": "DSC-01",
"title": "Assessments",
"cai_reference": "Assessments",
"homeowner_relevance": "Critical",
"development_status": "Active",
"summary": "...",
"vital_sign_dependencies": [
{ "code": "VS-01", "relevance": "..." }
],
"diagnostic_questions": [ "...", "..." ]
}
}
```
No `fields`, `perspectives`, or `_meta.form` keys — there is nothing for a
homeowner to fill in per category. The `diagnostic_questions` array is
**homeowner-facing**: shown inline in the checklist UI to help a homeowner
decide whether a category applies to their situation.
---
## 3. Record shape (dsc01 spool envelope)
One record represents one homeowner's self-assessment for one case. It is
a standalone record type — it does **not** embed into or extend a vs01
record. It carries a reference back to the relevant association's VS
profile via `association_slug`, the same slug key used in
`vs01/config.json`.
```json
{
"addon": "dsc01",
"contract_version": "1.0",
"association_slug": "kingsrow-wdca",
"association_channel_id": "123",
"submitted_at": "2026-06-13T00:00:00+00:00",
"standing": "participant",
"dsc_codes": ["DSC-01", "DSC-07"],
"narrative": "Free-text description of the homeowner's situation."
}
```
Field notes:
- `dsc_codes` — array of selected DSC-0X codes, in any order, at least one
required.
- `narrative` — single free-text field for the whole record (not per-code).
Optional but encouraged.
- `standing` — result of `dsc01_access_state()` at submission time
(`operator` / `participant` / `professional` / `public`), recorded for
provenance. Public submissions are rejected before reaching the spool
(see Access section below).
- `association_slug` / `association_channel_id` — pulled from
`vs01/config.json`, same as vs01's envelope. This is the cross-reference
that lets the corpus place a DSC record against the association's VS
profile without embedding one record type inside the other.
---
## 4. Access model
dsc01 reuses `vs01_access_state()`'s logic by reading the same
`vs01/config.json` associations/groups config — already implemented in
`dsc01_access_state()`. No separate access config file for dsc01.
- **public** — may read the category list, diagnostic questions, and the
checklist UI, but POST submissions are rejected (access wall shown,
same pattern as vs01's `vs01_access_wall()`).
- **participant** — may submit a DSC record for the association they're a
Privacy Group member of.
- **professional** — read access; submission behavior TBD (not blocking for
v1; treat as participant for now unless told otherwise).
- **operator** — full access, plus (future) a manage/review view over
submitted records, mirroring vs01's `vs01_render_manage()` stub.
---
## 5. Routes
- `/dsc01` — index. Lists the ten DSC categories with title + one-line
summary, similar to vs01's `vs01_render_index()` association list, but
here listing categories rather than associations.
- `/dsc01/{association_slug}` — landing page for that association. Shows
the checklist form (all 10 categories, each with diagnostic questions
expandable/inline) plus the narrative field and submit button.
- `/dsc01/{association_slug}` (POST) — submission handler. Validates
`dsc_codes` (at least one selected), builds the envelope (Section 3),
POSTs to orchestrator via `dsc01_post_to_orchestrator()`.
- `/dsc01/{association_slug}/manage` — operator-only, future. Review queue
for submitted DSC records (mirrors vs01's TMP-review stub).
No per-code routes (no `/dsc01/{slug}/DSC-01`) — unlike vs01, there is no
per-category form to navigate to.
---
## 6. Checklist UI
For each of the 10 categories, in DSC-01..10 order:
```html
<div class="dsc01-category">
<label>
<input type="checkbox" name="dsc_codes[]" value="DSC-01">
<strong>DSC-01</strong> — Assessments
</label>
<details class="dsc01-questions">
<summary>Diagnostic questions</summary>
<ul>
<li>Was the assessment properly authorized by the Board...?</li>
...
</ul>
</details>
</div>
```
After the 10 categories, a single `<textarea name="narrative">` for the
free-text case description, then submit.
`vital_sign_dependencies` are **not** shown in the homeowner checklist UI in
v1 — they are reference data for professional/operator views (future:
cross-link from a DSC record back to the association's VS profile to show
which VS conditions are most relevant given the selected DSC codes).
---
## 7. Spool / orchestrator
`dsc01_spool.php` mirrors `vs01_spool.php`'s `*_post_to_orchestrator()`
pattern:
- Reads `receiver_url` / `node_token` from `dsc01/config.json` (separate
config keys from vs01 — see `config.json.template`).
- POSTs the JSON envelope (Section 3) via curl, same header shape
(`Content-Type: application/json`, `X-Node-Token`).
- On success, shows a confirmation with a link back to the association
landing (`vs01/{slug}` or `dsc01/{slug}`, TBD which is more useful as the
"return to" target — likely `vs01/{slug}` since that's the association's
primary page).
- On failure, shows the same alert-danger pattern as vs01.
`records_file` (host-only, heredoc-created, never committed) is reserved
for a future local append-only log if the orchestrator round trip needs a
local fallback/cache — not required for v1 if the orchestrator receiver is
reliable. Document only; do not implement unless requested.
---
## 8. Out of scope for this pass
- Per-DSC-code detail pages.
- Operator manage/review UI (stub only, mirroring vs01's placeholder).
- Cross-linking rendered VS dependencies into the UI.
- Incubator (DSC-11..16, DSC-24) and Monitor categories — not represented
as schema files until promoted to Active per DSC-development-map.md.

View File

@@ -0,0 +1,6 @@
{
"_note": "Copy to config.json. Do not commit config.json — it contains secrets and installation-specific values. Access-state (associations/groups) is NOT duplicated here — dsc01 reads addon/vs01/config.json as the single source of truth for that.",
"receiver_url": "REPLACE — orchestrator receiver endpoint, e.g. https://orchestrator1.internal.diagnostics.kane-il.us/receive-dsc",
"node_token": "REPLACE — shared secret for node-to-orchestrator authentication",
"records_file": "REPLACE — absolute path to dsc01_records.json on the host, e.g. /var/www/hubzilla/addon/dsc01/dsc01_records.json"
}

View File

@@ -3,13 +3,16 @@
/** /**
* Name: DSC01 DSC Categories * Name: DSC01 DSC Categories
* Description: Public civic diagnostic — the legal surfaces where HOA governance disputes manifest. * Description: Public civic diagnostic — the legal surfaces where HOA governance disputes manifest.
* Version: 0.2.0 * Version: 0.3.0
* MinVersion: 11.0 * MinVersion: 11.0
* MaxVersion: 12.0 * MaxVersion: 12.0
*/ */
use Zotlabs\Extend\Widget; use Zotlabs\Extend\Widget;
require_once 'addon/dsc01/dsc01_renderer.php';
require_once 'addon/dsc01/dsc01_spool.php';
function dsc01_module() {} function dsc01_module() {}
function dsc01_load() { function dsc01_load() {
@@ -101,7 +104,7 @@ function dsc01_access_state($association_slug = '') {
intval($prof_gid), intval($prof_gid),
dbesc($observer) dbesc($observer)
); );
if ($r) return 'participant'; if ($r) return 'professional';
} }
return 'public'; return 'public';
@@ -127,7 +130,7 @@ function dsc01_access_wall($association_slug = '') {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CONTENT // CONTENT ROUTER
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function dsc01_content() { function dsc01_content() {
@@ -139,38 +142,50 @@ function dsc01_content() {
} }
$association_slug = argv(1) ?? ''; $association_slug = argv(1) ?? '';
$sub_route = strtolower(argv(2) ?? '');
// Index — list the ten DSC categories
if (!$association_slug) {
return dsc01_render_index();
}
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
if (json_last_error() !== JSON_ERROR_NONE || !isset($cfg['associations'][$association_slug])) {
return dsc01_render_not_found();
}
$access = dsc01_access_state($association_slug); $access = dsc01_access_state($association_slug);
// dsc01 is public — access wall only gates submission, not reading // Manage route — operator only (stub)
if ($sub_route === 'manage') {
if ($access !== 'operator') {
return dsc01_access_wall($association_slug);
}
return dsc01_render_manage($association_slug);
}
// POST — submission handler
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($access === 'public') { if ($access === 'public') {
return dsc01_access_wall($association_slug); return dsc01_access_wall($association_slug);
} }
// TODO: handle POST submission if (!dsc01_verify_csrf()) {
return dsc01_access_wall($association_slug); return '<div class="alert alert-danger">Invalid form token. Please reload and try again.</div>';
}
return dsc01_handle_post($association_slug, $access);
} }
return dsc01_render_main($access); // Association landing — checklist
return dsc01_render_landing($association_slug, $access);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RENDER // NOT FOUND
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function dsc01_render_main($access) { function dsc01_render_not_found() {
$out = '<div class="dsc01-content">'; return '<div class="dsc01-content"><div class="alert alert-warning">Association not found.</div></div>';
$out .= '<div class="dsc01-header mb-3">';
$out .= '<h2>Categories</h2>';
$out .= '<p class="text-muted">The legal surfaces where HOA governance disputes manifest, organized from the homeowner\'s perspective.</p>';
$out .= '</div>';
// TODO: render DSC categories
$out .= '<div class="dsc01-placeholder text-muted fst-italic">Content forthcoming.</div>';
$out .= '</div>';
return $out;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,201 @@
<?php
/**
* DSC-01 Renderer
* Renders the ten DSC category checklist. See DSC-RENDERER-SPEC.md.
*/
// ---------------------------------------------------------------------------
// SCHEMA LOADING
// ---------------------------------------------------------------------------
function dsc01_load_categories() {
$dir = 'addon/dsc01/diagnostic-categories';
$out = [];
$files = @glob($dir . '/DSC-*.json');
if (!$files) return $out;
sort($files);
foreach ($files as $file) {
$raw = @file_get_contents($file);
if ($raw === false) continue;
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) continue;
$meta = $data['_meta'] ?? null;
if (!$meta || empty($meta['code'])) continue;
$out[$meta['code']] = $meta;
}
ksort($out);
return $out;
}
// ---------------------------------------------------------------------------
// RENDER — INDEX (category overview)
// ---------------------------------------------------------------------------
function dsc01_render_index() {
$categories = dsc01_load_categories();
$out = '<div class="dsc01-content">';
$out .= '<div class="dsc01-header mb-3">';
$out .= '<h2>Diagnostic Surface Categories</h2>';
$out .= '<p class="text-muted">The legal surfaces where HOA governance disputes manifest, organized from the homeowner\'s perspective. Select an association to apply this checklist to a case.</p>';
$out .= '</div>';
if (empty($categories)) {
$out .= '<div class="dsc01-placeholder text-muted fst-italic">No categories defined.</div>';
$out .= '</div>';
return $out;
}
$out .= '<nav class="dsc01-category-nav" aria-label="Diagnostic Surface Categories">';
$out .= '<ul class="list-group list-group-flush">';
foreach ($categories as $code => $meta) {
$title = dsc01_h($meta['title'] ?? $code);
$summary = dsc01_h($meta['summary'] ?? '');
$out .= '<li class="list-group-item">';
$out .= '<strong>' . dsc01_h($code) . '</strong> — ' . $title;
if ($summary) {
$out .= '<p class="small text-muted mb-0">' . $summary . '</p>';
}
$out .= '</li>';
}
$out .= '</ul></nav>';
// Association picker — list associations from vs01/config.json
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$associations = $cfg['associations'] ?? [];
if (!empty($associations)) {
$out .= '<div class="dsc01-association-picker mt-4">';
$out .= '<h3>Apply this checklist</h3>';
$out .= '<ul class="list-group">';
foreach ($associations as $slug => $assoc) {
$name = dsc01_h($assoc['name'] ?? $slug);
$url = z_root() . '/dsc01/' . dsc01_h($slug);
$out .= '<li class="list-group-item"><a href="' . $url . '">' . $name . '</a></li>';
}
$out .= '</ul></div>';
}
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// RENDER — ASSOCIATION LANDING (checklist form)
// ---------------------------------------------------------------------------
function dsc01_render_landing($association_slug, $access, $submitted_codes = [], $submitted_narrative = '') {
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$assoc = $cfg['associations'][$association_slug] ?? [];
$name = dsc01_h($assoc['name'] ?? $association_slug);
$categories = dsc01_load_categories();
$out = '<div class="dsc01-content">';
$out .= '<div class="dsc01-header mb-3">';
$out .= '<h2>' . $name . '</h2>';
$out .= '<p class="text-muted">Diagnostic Surface Categories checklist. Select every category that applies to your situation.</p>';
$out .= '</div>';
if ($access === 'public') {
$out .= dsc01_render_checklist_readonly($categories);
$out .= '<div class="alert alert-info mt-3">';
$out .= 'DSC Categories are public. ';
$out .= '<a href="https://directory.diagnostics.kane-il.us/channel/theron">Complete the SASE process</a> ';
$out .= 'to submit a checklist for this association.';
$out .= '</div>';
$out .= '</div>';
return $out;
}
$form_url = z_root() . '/dsc01/' . dsc01_h($association_slug);
$out .= '<form method="post" action="' . $form_url . '" class="dsc01-form" novalidate>';
$out .= dsc01_csrf_token();
foreach ($categories as $code => $meta) {
$checked = in_array($code, $submitted_codes, true) ? ' checked' : '';
$title = dsc01_h($meta['title'] ?? $code);
$out .= '<div class="dsc01-category">';
$out .= '<label class="dsc01-category-label">';
$out .= '<input type="checkbox" name="dsc_codes[]" value="' . dsc01_h($code) . '"' . $checked . '> ';
$out .= '<strong>' . dsc01_h($code) . '</strong> — ' . $title;
$out .= '</label>';
if (!empty($meta['diagnostic_questions'])) {
$out .= '<details class="dsc01-questions">';
$out .= '<summary>Diagnostic questions</summary>';
$out .= '<ul>';
foreach ($meta['diagnostic_questions'] as $q) {
$out .= '<li>' . dsc01_h($q) . '</li>';
}
$out .= '</ul>';
$out .= '</details>';
}
$out .= '</div>';
}
$out .= '<div class="dsc01-narrative mt-3">';
$out .= '<label for="dsc01_narrative"><strong>Describe your situation</strong></label>';
$out .= '<textarea id="dsc01_narrative" name="narrative" class="form-control" rows="6">'
. dsc01_h($submitted_narrative) . '</textarea>';
$out .= '</div>';
$out .= '<div class="mt-3">';
$out .= '<button type="submit" class="btn btn-primary">Submit</button>';
$out .= '</div>';
$out .= '</form>';
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// RENDER — READ-ONLY CHECKLIST (public access)
// ---------------------------------------------------------------------------
function dsc01_render_checklist_readonly($categories) {
$out = '<div class="dsc01-checklist-readonly">';
foreach ($categories as $code => $meta) {
$title = dsc01_h($meta['title'] ?? $code);
$out .= '<div class="dsc01-category">';
$out .= '<strong>' . dsc01_h($code) . '</strong> — ' . $title;
if (!empty($meta['diagnostic_questions'])) {
$out .= '<details class="dsc01-questions">';
$out .= '<summary>Diagnostic questions</summary>';
$out .= '<ul>';
foreach ($meta['diagnostic_questions'] as $q) {
$out .= '<li>' . dsc01_h($q) . '</li>';
}
$out .= '</ul>';
$out .= '</details>';
}
$out .= '</div>';
}
$out .= '</div>';
return $out;
}
// ---------------------------------------------------------------------------
// RENDER — MANAGE (stub, operator only)
// ---------------------------------------------------------------------------
function dsc01_render_manage($association_slug) {
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$assoc = $cfg['associations'][$association_slug] ?? [];
$name = dsc01_h($assoc['name'] ?? $association_slug);
$out = '<div class="dsc01-content">';
$out .= '<h2>Manage — ' . $name . '</h2>';
$out .= '<p class="text-muted fst-italic">DSC record review forthcoming.</p>';
$out .= '</div>';
return $out;
}

View File

@@ -0,0 +1,153 @@
<?php
/**
* DSC-01 Spool Handler
* Validates POST submissions, builds the spool envelope,
* and POSTs to the orchestrator receiver.
* See DSC-RENDERER-SPEC.md — Record shape / Spool sections.
*/
// ---------------------------------------------------------------------------
// POST HANDLER
// ---------------------------------------------------------------------------
function dsc01_handle_post($association_slug, $access) {
$categories = dsc01_load_categories();
$valid_codes = array_keys($categories);
$raw_codes = $_POST['dsc_codes'] ?? [];
$codes = [];
if (is_array($raw_codes)) {
foreach ($raw_codes as $code) {
$code = strtoupper(trim((string) $code));
if (in_array($code, $valid_codes, true)) {
$codes[] = $code;
}
}
}
$codes = array_values(array_unique($codes));
$narrative = isset($_POST['narrative'])
? substr(strip_tags((string) $_POST['narrative']), 0, 8192)
: '';
// Validation — at least one category required
if (empty($codes)) {
$out = '<div class="alert alert-danger"><strong>Please select at least one category.</strong></div>';
$out .= dsc01_render_landing($association_slug, $access, $codes, $narrative);
return $out;
}
// Build spool envelope
$cfg_raw = @file_get_contents('addon/vs01/config.json');
$cfg = $cfg_raw ? json_decode($cfg_raw, true) : [];
$assoc = $cfg['associations'][$association_slug] ?? [];
$envelope = dsc01_build_spool_envelope(
$codes,
$narrative,
$association_slug,
$assoc['channel_id'] ?? '',
$access
);
$result = dsc01_post_to_orchestrator($envelope);
if ($result === true) {
$assoc_name = dsc01_h($assoc['name'] ?? $association_slug);
return '<div class="dsc01-content">
<div class="alert alert-success">
Your checklist for ' . $assoc_name . ' has been submitted.
<a href="' . z_root() . '/vs01/' . dsc01_h($association_slug) . '">
Return to ' . $assoc_name . '
</a>
</div>
</div>';
}
$out = '<div class="alert alert-danger">
Submission failed. Please try again or contact the operator.
<br><small>' . dsc01_h($result) . '</small>
</div>';
$out .= dsc01_render_landing($association_slug, $access, $codes, $narrative);
return $out;
}
// ---------------------------------------------------------------------------
// SPOOL ENVELOPE
// ---------------------------------------------------------------------------
function dsc01_build_spool_envelope($dsc_codes, $narrative, $association_slug, $channel_id, $standing) {
return [
'addon' => 'dsc01',
'contract_version' => '1.0',
'association_slug' => $association_slug,
'association_channel_id' => (string) $channel_id,
'submitted_at' => date('c'),
'standing' => $standing,
'dsc_codes' => $dsc_codes,
'narrative' => $narrative,
];
}
// ---------------------------------------------------------------------------
// ORCHESTRATOR POST
// ---------------------------------------------------------------------------
function dsc01_post_to_orchestrator($envelope) {
$config = dsc01_load_config();
$receiver_url = $config['receiver_url'] ?? '';
$node_token = $config['node_token'] ?? '';
if (!$receiver_url) {
logger('dsc01_spool: receiver_url not configured');
return 'Orchestrator receiver URL not configured.';
}
$payload = json_encode($envelope);
if ($payload === false) {
logger('dsc01_spool: failed to encode envelope');
return 'Failed to encode submission.';
}
$ch = curl_init($receiver_url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Node-Token: ' . $node_token,
],
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
if ($curl_error) {
logger('dsc01_spool: curl error: ' . $curl_error);
return 'Network error: ' . $curl_error;
}
if ($http_code === 200 || $http_code === 201) {
return true;
}
logger('dsc01_spool: orchestrator returned HTTP ' . $http_code . ': ' . $response);
return 'Orchestrator error (HTTP ' . $http_code . ').';
}
// ---------------------------------------------------------------------------
// CONFIG
// ---------------------------------------------------------------------------
function dsc01_load_config() {
$path = 'addon/dsc01/config.json';
$raw = @file_get_contents($path);
if ($raw === false) return [];
$data = json_decode($raw, true);
return (json_last_error() === JSON_ERROR_NONE) ? $data : [];
}