Manual · Chapter 3 of 3
Architecture
Append-only fact log, pure-function views, hypermedia over SSE. One Go binary, one SQLite file, five env vars. No build step, no Docker, no Redis, no second process. One JSON endpoint (/ranges/active) for the map canvas; everything else is HTML.
The stack, from churn to stillness
Each layer must carry fewer moving parts and change less often than the one above. The basement has to outlive everything stacked on it, so the bottom is picked for stillness — SQLite's file format has been stable since 2004 with a public commitment to 2050.
parts │ churn
┌──────────────────────────────────────────────────────────┐
│ Browser HTML + Datastar SSE patches; cc-* components │ many │ 60Hz
│ Edge Cloudflare ──tunnel──▶ cloudflared │ 1 │ ~year
│ HTTP net/http · validate → Assert / render template │ N │ per-feature
│ Runtime Snapshot reads · single-writer goroutine · Hub │ 3 │ rare
│ Schema facts(entity, attribute, value, asserted_at, │ 1 │ ~decade
│ asserted_by) — typeless value, catalog.go │ table │
│ Storage SQLite WAL · 1 file · public-domain C source │ 1 │ frozen
│ │ file │ 'til 2050
└──────────────────────────────────────────────────────────┘
Layout is one Go file per concern: main.go (wiring + startup migrations), catalog.go (attribute specs + validator), facts.go (snapshot reads + single-writer goroutine), views.go (range presenter + lineage), identity.go (cookies + operator), hub.go (in-process pub/sub), map.go (the one JSON endpoint), feeds.go (ICS + RSS), pwa.go, backup.go, seed.go, plus a handlers_*.go per surface (ranges, tap, profile, operator, explorer). New attribute kinds are an append to catalog.go — never a SQL migration, because value is typeless.
Data model
CREATE TABLE facts (
id INTEGER PRIMARY KEY,
entity TEXT NOT NULL,
attribute TEXT NOT NULL,
value, -- typeless; NULL = excision
asserted_at INTEGER NOT NULL,
asserted_by TEXT
);
Every observation is one row, appended, never updated. Cardinality is always one — latest fact wins. Two entity kinds: participant (carries the verify lifecycle and a meeting-point pin) and range (the unifying entity for events, projects, paint, posts). Tombstoning is also an append: assert tombstoned=1 plus a NULL excision per content attr. HEAD picks the excision; past-LogAt reads still see the original.
See it live in the Raw Data Explorer: catalog → kinds → entities → raw facts, with the read-path SQL on every page.
Ranges and sprites
A range has a stretch of time (starts_at, ends_at, plus before/after visibility widening) and optionally a place (lat, lng). Lifecycle is open ↔ claimed → done; the claimer and claim-time live on asserted_by / asserted_at of the latest lifecycle = claimed fact. A sprite is three facts on the range: layer (background / ground / foreground), pixels_w/pixels_h, and pixels (raw RGBA). Footprint is pixels_w * SPRITE_SCALE degrees, anchored on (lat, lng). Visibility at clock T: starts_at - before ≤ T ≤ ends_at + after.
Two time axes
A snapshot carries two independent times; the map page exposes a scrubber for each.
ClockAt | LogAt | Mode |
|---|---|---|
| now | HEAD | live |
| past | HEAD | past as we now believe it was |
| past | past | forensic: what we claimed at log L about clock T |
Writers are always at HEAD — editing the past means asserting a fact with starts_at in the past, not rewinding the log. HEAD is a zero-cost no-op on the hot path.
Request shape
Every write: validate, compose []Fact, Assert, hub.Notify(), 204. The hub is in-process pub/sub; /stream SSE re-renders fragments against a fresh snapshot on every notify. Every read: pick a snapshot, render a template, write the bytes. The only non-HTML endpoint is GET /ranges/active — JSON the <cc-map> canvas consumes. All other routes (public reads, writes, operator, explorer, PWA) are enumerated in the Explorer.
Identity
Two HMAC-signed cookies: pid (caller's participant id, 1 year) and op (operator marker, session); both HttpOnly, SameSite=Lax. Operator status is a fact on the participant — the cookie is just a session marker. Tap-in is the cost of fake-accounting: not "make a new email" but "walk to where the world meets and convince a real neighbor." POST /tap/complete transitions a pending caller to verified in one transaction; the lifecycle fact's asserted_by is the voucher, and GPS lands on the caller as verified_lat/verified_lng.
Deployment and operations
The chronicle runs on a Pi Zero 2 W in an IP65 enclosure on a pole, off-grid. A stationary bike with a PMA generator charges a ~200 Wh LiFePO4 battery; an hour of pedaling at ~80–100 W covers a day's draw. cloudflared holds a long-lived outbound TLS connection to Cloudflare's edge; the Pi binds 127.0.0.1:8080 and the tunnel is the only path in. Litestream ships SQLite WAL frames to S3 continuously. If nobody pedals, the box stops — that's the right behavior. Roughly $500 one-time gear, $15–30/month for SIM + storage.
The public hostname is a locality subdomain — <neighborhood>.<city>.<region>.<tld> — so the URL itself names the place the box belongs to. The binary stays place-agnostic: base_url is derived from r.Host, and each neighborhood that adopts the software gets its own subdomain under the same scheme rather than a path or query parameter.
CC_ADDR=:8080
CC_DB=/var/lib/communal/communal.db
CC_OPERATOR_TOKEN=<opaque; visited as /operator/<token>>
CC_BACKUP_INTERVAL=1h # 0 disables
CC_BACKUP_DIR=<default: <CC_DB>.snapshots>
Cookie HMAC and the planned VAPID keypair live at <CC_DB>.secrets (mode 0600), generated on first run. Deleting the file rotates the key and logs everyone out. POST /operator/prune hard-deletes excised rows; GET /export.db streams a fresh VACUUM INTO copy.
Antifragility checks
- The binary must run with the database deleted (it recreates the schema).
- The binary must run with no outbound network on the hot path.
- Removing any feature must be a small diff. If it can't be cleanly removed, it shouldn't be added.