docs: audit corrections, coverage pages, screenshots, and em-dash removal

Finalizes the docs section after an adversarial audit (code review + per-page
fact-check + coherence critic) and two content requests.

- Accuracy: fixed 10 invented cross-link slugs, the admin-vs-board gating claims
  the verifiers caught (permissions matrix, /settings, invites, onboarding,
  connect-payments, proposal withdraw, Stripe reconciliation, member fields,
  door.operate), and 2 issues in the new pages (project area-at-creation, forum
  thread-delete role).
- Coverage: added 5 how-to pages so every module is documented (recurring Stripe
  dues, tasks & chores, projects, comms & forum, ops knowledge base + secrets
  vault). 34 pages total.
- Screenshots: 12 captioned app screenshots under public/docs-media/, embedded
  as figures. Each tutorial now has 2-3 step screenshots.
- Style: removed all em-dashes/en-dashes/ellipses from the docs (natural ASCII
  punctuation).
- Handoff (docs/HANDOFF-audit-cleanup.md) updated with the current state.

typecheck 0 · lint 0 errors · build ok (all 34 /docs pages prerendered)
This commit is contained in:
Moheeb Zara
2026-08-05 03:15:07 -07:00
parent 16229ae523
commit 8edc9da611
50 changed files with 610 additions and 202 deletions
+7 -7
View File
@@ -1,6 +1,6 @@
Governance in a hackerspace is mostly procedure: who gets to decide, how many people have to weigh in, and what counts as a pass. The platform's governance kernel encodes those procedures as data so they can't be argued about after the fact. This page explains why quorum, thresholds, recusal, anonymity, and SLAs are modeled the way they are.
The whole design follows one rule: **privacy default for individuals, transparency default for institutions.** A member's identity when reporting harassment is protected; the vote a board member cast on a proposal is public forever. Every schema choice below falls out of that principle plus a second one — no hardcoded politics. Quorum percentages, voting windows, thresholds, and SLAs are per-space **DB defaults** — columns on the `spaces` table (`default_quorum_percent`, `default_voting_window_hours`, `default_threshold`, `incident_sla_hours`) that ship sensible values but are not currently editable in the UI. Each proposal sets its own quorum, threshold, and window at creation, seeded from those defaults.
The whole design follows one rule: **privacy default for individuals, transparency default for institutions.** A member's identity when reporting harassment is protected; the vote a board member cast on a proposal is public forever. Every schema choice below falls out of that principle plus a second one, no hardcoded politics. Quorum percentages, voting windows, thresholds, and SLAs are per-space **DB defaults**, columns on the `spaces` table (`default_quorum_percent`, `default_voting_window_hours`, `default_threshold`, `incident_sla_hours`) that ship sensible values but are not currently editable in the UI. Each proposal sets its own quorum, threshold, and window at creation, seeded from those defaults.
## Why quorum is a floor plus a percent
@@ -10,7 +10,7 @@ A single percentage breaks at the extremes. Ten percent of 400 members is a reas
quorum_required = GREATEST(quorum_floor, CEIL(member_count * quorum_percent / 100))
```
The member count is not "everyone in the database" — it counts only members with status `current` or `late` who are `approved`. Unverified, inactive, and unapproved rows don't inflate the denominator. Defaults are `default_quorum_percent = 10` and `default_quorum_floor = 1`; a real space typically raises the floor to something like 8 ("10% or 8, whichever is greater").
The member count is not "everyone in the database", it counts only members with status `current` or `late` who are `approved`. Unverified, inactive, and unapproved rows don't inflate the denominator. Defaults are `default_quorum_percent = 10` and `default_quorum_floor = 1`; a real space typically raises the floor to something like 8 ("10% or 8, whichever is greater").
Crucially, quorum is **computed once, when the proposal opens, and frozen** onto the proposal row (`quorum_required`, `quorum_percent`, `quorum_floor`). If three people join mid-vote, the bar doesn't move under the voters. That freezing is what makes the number un-disputable: it's stated on the proposal page before anyone votes.
@@ -25,18 +25,18 @@ Quorum asks "did enough people show up?" Thresholds ask "of those who took a sid
| `three_fourths` | yes × 4 ≥ (yes + no) × 3 |
| `unanimous` | no = 0 and yes > 0 |
The denominator is always `yes + no`, never total voters. Using integer cross-multiplication instead of floating-point division avoids rounding disputes on the exact boundary. A proposal only passes if quorum is met *and* the threshold clears — both are recomputed on every vote so the proposal page shows a live, honest tally. See [Run a proposal](/docs/how-to/run-a-proposal) for the operator's view and [Governance reference](/docs/reference/governance) for the full enum tables.
The denominator is always `yes + no`, never total voters. Using integer cross-multiplication instead of floating-point division avoids rounding disputes on the exact boundary. A proposal only passes if quorum is met *and* the threshold clears, both are recomputed on every vote so the proposal page shows a live, honest tally. See [Run a proposal](/docs/how-to/run-a-proposal) for the operator's view and [Governance reference](/docs/reference/governance) for the full enum tables.
## Why recusal is a first-class position, not a silence
A board member with a conflict of interest could just not vote — but then there's no record of *why* they stayed out, and the count can't distinguish a principled recusal from apathy. So `recused` is one of four vote positions (`yes`, `no`, `abstain`, `recused`), and a database constraint requires a non-empty `recusal_reason` to use it. The recusal is logged permanently and visible to every member, because a recusal record is exactly the kind of institutional transparency the model protects. Votes are never deleted; a member's voting history is a query, not a leak.
A board member with a conflict of interest could just not vote, but then there's no record of *why* they stayed out, and the count can't distinguish a principled recusal from apathy. So `recused` is one of four vote positions (`yes`, `no`, `abstain`, `recused`), and a database constraint requires a non-empty `recusal_reason` to use it. The recusal is logged permanently and visible to every member, because a recusal record is exactly the kind of institutional transparency the model protects. Votes are never deleted; a member's voting history is a query, not a leak.
## Why anonymity is honest, not cryptographic
Incident reports (`/incidents`) can be filed anonymously, but the platform deliberately does **not** promise cryptographic anonymity — the database admin can always read the row. What it provides instead is an opaque, server-generated `reporter_token` that lets an anonymous reporter track their own case without exposing their identity in any list. When `is_anonymous` is set, `reporter_id` is left null. Named subjects of a report don't see it by default; dispositions stay board-only until a policy says otherwise, enforced by row-level security and the `incident_updates` visibility levels (`reporter_only`, `all_parties`, `board_only`). Promising more than the storage model can deliver would be dishonest, so the model promises exactly what it enforces. See [Handle incidents](/docs/how-to/handle-incidents).
Incident reports (`/incidents`) can be filed anonymously, but the platform deliberately does **not** promise cryptographic anonymity, the database admin can always read the row. What it provides instead is an opaque, server-generated `reporter_token` that lets an anonymous reporter track their own case without exposing their identity in any list. When `is_anonymous` is set, `reporter_id` is left null. Named subjects of a report don't see it by default; dispositions stay board-only until a policy says otherwise, enforced by row-level security and the `incident_updates` visibility levels (`reporter_only`, `all_parties`, `board_only`). Promising more than the storage model can deliver would be dishonest, so the model promises exactly what it enforces. See [Handle incidents](/docs/how-to/handle-incidents).
## Why every incident has an SLA clock
The failure mode the incident system exists to prevent is the complaint that vanishes. So an incident isn't just filed — on insert it gets an `sla_response_by` deadline (`created_at + incident_sla_hours`, default 72 hours / 3 days). The status ladder (`received → under_review → decided → appealed → closed`) means action requires an explicit, timestamped transition, and a dismissed report exposes an appeal path that becomes a normal proposal with `parent_incident_id` set. Silence stops being a way to avoid a decision, because the schema records the absence of one.
The failure mode the incident system exists to prevent is the complaint that vanishes. So an incident isn't just filed, on insert it gets an `sla_response_by` deadline (`created_at + incident_sla_hours`, default 72 hours / 3 days). The status ladder (`received → under_review → decided → appealed → closed`) means action requires an explicit, timestamped transition, and a dismissed report exposes an appeal path that becomes a normal proposal with `parent_incident_id` set. Silence stops being a way to avoid a decision, because the schema records the absence of one.
The through-line: institutional memory is retained and public — proposals, votes, and policy versions are immutable and cross-referenced — while individual reporters keep the privacy the model was built to give them.
The through-line: institutional memory is retained and public (proposals, votes, and policy versions are immutable and cross-referenced) while individual reporters keep the privacy the model was built to give them.