---
title: STARTER KIT — CONFIGURATION & CONTRACTS
slug: starter-kit-configuration
layer: STARTER KIT
layer_order: 4
order: 104
description: The implementation contracts — environment variables, the canonical-naming algorithm, and the catalog.json + submission.json data schemas.
---

> **FLUX DOCUMENTATION SYSTEM**
> Layer 11 — STARTER KIT | starter-kit-configuration
> flux.dantesisofo.com/wiki/starter-kit-configuration/

# STARTER KIT — CONFIGURATION & CONTRACTS

Implement this page first. Everything else depends on the config module, the
naming function, and the two JSON schemas defined here.

---

## 1. CONFIG MODULE (`config.py`)

One module reads `.env` (via `python-dotenv`) and exposes constants. **No
identifier may be hardcoded anywhere else.**

| Env var | Meaning | Example |
|---|---|---|
| `FLUX_BUCKET` | S3 bucket name | `your-cityflux-site` |
| `FLUX_CF_DIST` | CloudFront distribution ID | `E0000000000000` |
| `FLUX_REGION` | AWS region | `us-east-1` |
| `FLUX_BASE_URL` | public base URL | `https://your-domain` |
| `FLUX_AUTHOR` | photographer/display name | `Your Name` |
| `COLLECTION` | hub membership tag | `boston-in-flux` |
| `CORRIDOR_TARGET` | coverage goal (int) | `50` |
| `ROUTE_COLOR` | route accent hex | `#b84000` |
| `TILE_URL` | Leaflet tile template | CARTO `light_all` |
| `PORTAL_USER` / `PORTAL_PASS` | local portal auth | — |
| `ANTHROPIC_API_KEY` | optional AI about-text | blank = template |

Derived constants: `HUB_KEY="hub/index.html"`, `EMBED_KEY="hub/embed/index.html"`,
`LISTING_KEY="catalog/index.html"`, `CATALOG_KEY="data/catalog.json"`,
`HUB_URL="/hub/"`, `EMBED_URL="/hub/embed/"`, `ROUTE_MAX_POINTS=120`.

---

## 2. CANONICAL NAMING (THE NAMING AUTHORITY)

One street name → every identifier. Deterministic, pure. Implement exactly:

```python
STREET_TYPES = {  # normalize street-type tokens to canonical abbreviations
  "avenue":"AVE","ave":"AVE","av":"AVE","street":"STREET","st":"STREET","str":"STREET",
  "boulevard":"BLVD","blvd":"BLVD","road":"RD","rd":"RD","drive":"DR","dr":"DR",
  "lane":"LN","ln":"LN","place":"PL","pl":"PL","court":"CT","ct":"CT",
  "highway":"HWY","hwy":"HWY","parkway":"PKWY","pkwy":"PKWY","pike":"PIKE",
  "terrace":"TER","ter":"TER",
}

def canonical(street_name: str) -> dict:
    words = re.sub(r"[^A-Za-z0-9 ]+", " ", street_name or "")
    words = re.sub(r"\s+", " ", words).strip()
    if not words:
        return {"title":"","slug":"","folder":"","export_name":"","collections":[COLLECTION]}
    toks = [STREET_TYPES.get(w.lower(), w.upper()) for w in words.split(" ")]
    title = "_".join(toks) + "_IN_FLUX"          # e.g. PASSYUNK_AVE_IN_FLUX
    slug  = slugify(" ".join(toks) + " in flux") # e.g. passyunk-ave-in-flux
    return {"title":title, "slug":slug, "folder":slug,
            "export_name":f"{slug}-project.zip", "collections":[COLLECTION]}

def slugify(text):  # lower, non-alnum -> '-', collapse, trim, cap length
    return re.sub(r"[^a-z0-9]+","-",text.lower()).strip("-")[:60].strip("-")
```

**Contract tests:** `canonical("Passyunk Ave").title == "PASSYUNK_AVE_IN_FLUX"`;
`canonical("Passyunk Avenue").slug == "passyunk-ave-in-flux"`;
`canonical("Broad Street").title == "BROAD_STREET_IN_FLUX"`.

---

## 3. SCHEMA — `catalog.json` (THE INDEX / SOURCE OF TRUTH)

Lives at `s3://YOUR_BUCKET/data/catalog.json`, mirrored locally. The hub, embed,
and listing render **only** from this file.

```jsonc
{
  "schema_version": "1.1",
  "last_updated": "2026-06-09T12:00:00Z",
  "reserved_slugs": ["passyunk-ave-in-flux", ...],   // tombstones; never reuse
  "walks": [ WalkRecord, ... ]
}

// WalkRecord:
{
  "walk_id": "WALK_001",                         // sequential, frozen
  "slug": "passyunk-ave-in-flux",                // frozen once published
  "title": "PASSYUNK_AVE_IN_FLUX",
  "photographer_name": "Your Name",
  "location_text": "Passyunk Ave, Your City",
  "date_range": "2026-06-06",
  "total_photos": 118,
  "geotagged_count": 116,
  "distance_km": 9.0,                            // CANONICAL unit = km
  "duration_minutes": 154,
  "duration_formatted": "2h 34m",
  "cover_image": "passyunk-ave-in-flux/large/<photo_id>.jpg",
  "public_url": "/passyunk-ave-in-flux/",
  "published_at": "2026-06-06T18:00:00Z",
  "collections": ["boston-in-flux"],             // hub membership (array)
  "route": [[39.913,-75.242], ...],              // downsampled <= ROUTE_MAX_POINTS
  "bbox": [minLat, minLon, maxLat, maxLon]
}
```

**Rules:** `slug` and `walk_id` are immutable after first publish; removed slugs
stay in `reserved_slugs`. `route`/`bbox` are an index projection of the full route
(the full route stays in the draft) — storing them here lets the hub render with
zero per-project fetches.

---

## 4. SCHEMA — `submission.json` (THE DRAFT)

One per project, in the draft run folder. The Worker writes it; the Publisher reads
it. This **is** the draft — there is no separate draft model.

```jsonc
{
  "schema_version": "1.0",
  "slug": "passyunk-ave-in-flux",
  "status": "new|uploaded|generating|draft_ready|publishing|published|error",
  "created_at": "...", "updated_at": "...",
  "submitter": { "name": "Your Name", "email": "" },
  "walk": {
    "title": "PASSYUNK_AVE_IN_FLUX",
    "location_text": "Passyunk Ave, Your City",
    "collections": ["boston-in-flux"]            // explicit hub membership
  },
  "photos": [ Photo, ... ],
  "stats": Stats,
  "about_generated": "A walk through ...",
  "about_source": "ai|template",
  "contact_sheet": "contact_sheet.png",
  "zine": { "selection": ["<photo_id>", ...] }   // optional final 36; else null
}

// Photo:
{
  "id": "<sha256[:16]>", "seq": "001",
  "original_filename": "R0010001.JPG",
  "timestamp": "2026:06:06 11:52:17", "timestamp_unix": 1781000000,
  "lat": 39.913, "lon": -75.242, "address": "Passyunk Avenue, ...",
  "make": "...", "model": "...", "lens": "...", "focal": "18mm",
  "aperture": "f/8.0", "shutter": "...", "iso": "ISO 200",
  "width": 3504, "height": 2336, "file_size": 4500000,
  "geotagged": true,
  "large": "large/<id>.jpg", "thumb": "thumbs/<id>.jpg"
}

// Stats:
{
  "total_photos": 118, "geotagged_count": 116, "geotagged_pct": 98,
  "duration_minutes": 154, "duration_formatted": "2h 34m",
  "date_range": "2026-06-06", "start_time": "...", "end_time": "...",
  "distance_km": 9.0,
  "route": [[lat,lon], ...],                      // FULL route (not downsampled)
  "first_photo_id": "...", "last_photo_id": "..."
}
```

`photo.id = sha256(file)[:16]` (dedup key + derivative filename). `seq` is assigned
after chronological sort by `timestamp_unix`.

---

## 5. FILE / DIRECTORY LAYOUT (LOCAL)

```
cityflux/
  config.py            # §1
  naming.py            # §2 canonical()
  worker.py            # ARCHITECTURE
  publisher.py         # DEPLOYMENT
  deploy.py            # aws s3 sync wrapper (full-site)
  portal/app.py        # optional Flask studio
  zine/                # client-side PDF + vendored leaflet/qrcode/jspdf
  data/catalog.json    # the index (seed: empty walks[])
  _drafts/<slug>/      # per-project: originals/ large/ thumbs/ submission.json contact_sheet.png
  .env  .env.example  .gitignore  requirements.txt
```

`_drafts/`, `.env`, `data/catalog.json` (except an empty seed) are gitignored.

---

## 6. ACCEPTANCE CHECKS

- `config` raises a clear error if `FLUX_BUCKET`/`FLUX_CF_DIST`/`FLUX_BASE_URL` are unset.
- `canonical()` passes the §2 contract tests.
- A hand-written minimal `catalog.json` (one WalkRecord) validates against §3 and
  the hub builder renders it.

Next: [SETUP](../starter-kit-setup/).

---

FLUX_WIKI_v2.0 — flux.dantesisofo.com/wiki/starter-kit-configuration/
