# LANE A — NOTES (cross-lane answers from CityGen) Durable answers to questions other lanes raised against Lane A. Full status in `A-progress.md`. ## Round 27 wave 5 (2026-07-17) — THE FACADE FIX: it was every town, not just the real ones ### → F: **your files hold the old covenant hashes — they are now wrong, and only you can fix them** This is the one thing blocking your re-gate. `scaffold_check` is **RED** until you re-pin, and I can't touch your files. Exact values (verified byte-identical across two fresh processes): | your file | constant | old | **new** | |---|---|---|---| | `tools/flags_check.py:30` | `GOLDEN_HASH` | `0x3fa36874` | **`0x5f76e76`** | | `tools/flags_check.py:31` | `GIG_GOLDEN` | `0xb1d48ea1` | **`0xec7a2d39`** | | `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.melbourne` | `0x34cfdec0` | **`0x9c7e76b3`** | | `tools/qa/scaffold_check.mjs:231` | `TOWN_GOLDENS.katoomba` | `0x0f652510` | **`0xf3aafec8`** | | `tools/qa/scaffold_check.mjs:225` | the comment quoting all three | — | same three | | `tools/qa/tour_v5.py:42`, `tour_shots.py:10` | captions quoting `0x3fa36874` | — | **`0x5f76e76`** | Also: `flags_check.py:148` greps stdout for the literal `'3fa36874'` — that will silently stop matching. ### The finding: the ruling's premise was wrong — **synthetic was broken too** The wave-5 ruling says *"synthetic follows B's canon so every golden has been green over a wrong product since R18"*. Half right, and the wrong half is load-bearing. **Synthetic does not follow B's canon** — it is broken identically, and so is the marched path, and so are the three checked-in fixtures. It is not "every real town since R18": it is **every town, every path, since v1**. Measured in B's canon before touching anything, then confirmed with my own eyes in the browser: | path | before | after | |---|---|---| | synthetic (`plan.js`) — **the classic covenant** | **0 toward / 681 away** | 681 / 0 | | marched-osm + the 3 fixtures | 0 / 80, 0 / 95, 0 / 19, 0 / 12 | all toward | | real-roads (B's finding) | 0 / 72 katoomba | **72 / 72** — matches B exactly | **The screenshot is the proof:** standing on the synthetic main street at `?classic=1`, lot 0 — three blank grey boxes. Walk *behind* them, into the block, and there is NUMBAT PLAYTHINGS: sign, door, windows, awning, facing the dirt. Same camera after the fix: CROWN TOYS & GAMES · MARBLES & KITES · NUMBAT PLAYTHINGS, a strip of shopfronts facing the road. **637 of 681 synthetic lots had their facade pointing at no street at all.** **Why it survived**: all four sites are the same one-sign error — `atan2(outward)` where the intent was `atan2(−outward)` — and *every one of them says so in its own comment* (`plan.js:122` "facade faces back down the outward normal, to the street"). The code never did what its comment said, and CITY_SPEC:71 blessed the result. Nobody was wrong; the spec was. ### The covenant moved — John ruled it Fixing synthetic necessarily moves `0x3fa36874`, frozen "forever" since R16. That was not mine to decide. **John's ruling: amend the covenant, fix all paths** — the covenant is anti-drift machinery, not a defect preservative. **51 goldens re-pinned in one commit** (classic `0x3fa36874 → 0x5f76e76`, gig `0xb1d48ea1 → 0xec7a2d39`, 3 fixtures, 23 towns × base+gig). The amendment is recorded in CITY_SPEC as a one-time, ruling-gated precedent — from this hash forward the law reads as it always did. ### A second bug the fix exposed (fixed here, counted) `buildRealRoads` seated blocks at `KERB = 4 m` **from the centreline** — but a `main` carriageway is 10 m wide, so its kerb is at 5 m. **Every main-street building has stood 1 m inside the road since R18.** The footprint never moved; only which face is the front — so it was invisible until the shopfront turned around and its poster landed in the traffic lane (−1.07 m). Blocks now seat behind the kerb per A's own corridor law (`roadWidth/2 + FOOTPATH`). Pack drops **6.4% → 6.6%**, counted, not hidden. ### The cross-convention gate — built to F's lesson, not to a spec line F proved that measuring lots against CITY_SPEC:71's own convention is **self-agreement**: 72/72 "facing" over a town of blank walls. So the new gate does not restate a spec line. It **replicates Lane B's own placement maths** from `buildings.js` (`toWorld(lot, 0, y, d/2 + 0.02)`) and asks a physical question neither spec line can fudge: *does the quad the renderer actually draws land nearer the street than the lot centre?* It fires if A's `ry` sign drifts **or** if B's `zf` sign drifts. It would have caught this on day one of v1. ## Round 27 (2026-07-17) — v5.0: the docs freeze — CITY_SPEC v5 + the risk list judged (ledger #6) ### → F + Fable: **A's docs half is done and the tree is green; nothing of mine gates your tag** selfcheck **ALL GREEN 156,212/156,212**, classic `0x3fa36874` / gig `0xb1d48ea1` frozen, **no golden moved** (none should — tier 2 touches no plan, no cache, no atlas bytes). CITY_SPEC has its **v5 section** (the tier ladder · `sourcing` vs `tier` · the three id-space fences · the manifest · Ruling 2 · A's one seam), and the charter risk list is judged line by line in `V5_REAL_SHOP.md`. ### The risk list: I did NOT pre-retire your gate Written in wave 2, so **risk #1 (latency/stutter at tier 2) is CARRIED, not retired** — your reader and kill-the-server gate don't exist yet, and **nothing retires that line except the gate going green**. Retiring it from a doc while the code is unwritten would be the exact species this epoch spent four holds catching: a rule meeting a case that doesn't exist yet. #2 retired (measured), #3 retired (designed out), #4 **split** — write-back retired by John's ruling-as-mechanism, the `gone` read carried to your #4. Two new carried lines (#6 orphaned atlas, #7 "real" is one shop wide). ### The gate that armed itself — worth seeing, since it's the law working unattended My R25 orphaned-atlas gate **SKIPped** on `newtown_godverse` all through R25 ("no committed atlas keys to this town" — subject absent, so it said so instead of passing). The moment G's 14 mint atlases landed, it **started binding on its own** and passed: selfcheck went 156,211 → **156,212**, and that +1 is the gate arming itself. Nobody edited it. That's what the vacuous-gate law buys — a gate that waits, in the open, for its subject. **Live near-miss on the books → G:** `redhill_godverse`'s lift drops keyed shop **2286 "Empire Revival"**; you stocked its numeric neighbour **2287**. Nothing is orphaned and the gate is green — but **stocking 2286 would orphan it today**, and the hazard grows with every shop stocked. Carried as risk #6. ### One correction to the round doc's framing, for the record The brief lists "the manifest" and "sourcing" as if `tier` were superseded. The artifacts say otherwise, and **C's ruling is the precise one**: every atlas index carries **both** — `tier: 1` (the ladder rung; a mint atlas is *still* a static file, so still rung 1) **and** `sourcing: "real"|"mint"` (where the contents came from). They are different axes. The spec documents both, because collapsing them is exactly how a mint crate would come to read as real. ## Round 25 (2026-07-17) — v5.0-alpha: the sweep reconciled with Ruling 2, six goldens pinned (ledger #1) ### → F: **the tree is green — all 8 reds are gone, nothing of mine is pending** (the handshake) selfcheck **ALL GREEN 156,211/156,211**; scaffold + consistency green; classic `0x3fa36874` and gig `0xb1d48ea1` frozen. The six goldens are pinned: `newtown_godverse` base `0xcf69b387` / gig `0x1e266f60`, `redhill_real` base `0x6046700f` / gig `0x673785ee`, `redhill_godverse` base `0xb2224939` / gig `0xc6f80e18`. Cross-checked two ways before pinning — **byte-identical across two fresh processes**, and the three values G stated independently in `86e2985` match mine exactly. **Your third attempt is unblocked from my side.** Two lines still print and **both are deliberate, neither is pending**: - `⊘ SKIP real/newtown_godverse: orphaned-atlas check` — correct: the only committed atlas (3962749) keys to Red Hill, so newtown has no subject. The vacuous-gate law says say so rather than pass quietly. - `⚠ real/redhill_godverse: 1 keyed shop(s) dropped by the lift — 2286 "Empire Revival"` — an honest disclosure, not a defect (see below). ### My R24 gate asserted Ruling 2 away — that's the whole of my 6 reds, and it was my error I built the identity gate on an assumption that was true when I wrote it and false the moment G shipped: that a godverse cache is *all* census shops. Ruling 2 makes it **mixed** — a keyed GODVERSE layer plus an inherited texture layer — so `newtown_godverse` (18 keyed + 54 texture) and `redhill_godverse` (10 + 27) are **healthy towns my gate called broken**. Every keyed assert now runs **per-layer, over the keyed subset only**; unkeyed texture shops raise nothing. The validator's "N/M missing" warn is gone with it — the only honest requirement left at that layer is that a `godverse+osm` cache have a **non-empty** godverse layer at all (else it's an osm cache wearing the wrong `source`), which stays a warn for the R24 charter reasons. ### The uniqueness check was broken in both directions — rebuilt so it can actually fail The R24 version was `new Set(all ids).size === shops.length`. Every `undefined` collapses into **one** Set entry, so it (a) failed structurally on any mixed cache — 54 texture shops became one entry — and (b) could **hide a real duplicate behind that same collapse**. It now runs over **defined ids only**. And per the vacuous-gate law I made it prove it bites: `validateTownCache` rejects duplicates at the front door, so a dup can never reach the plan that way — the test feeds one **straight to the lift** (`generatePlanOSM` does not validate) and asserts the predicate catches it, plus a healthy mixed cache passes the same predicate. A check nobody can watch fail is not a check. ### New — the orphaned-atlas gate (my R24 finding, now enforced instead of just filed) The lift legitimately drops shops (overlap-resolve, counted since R19). Dropping a shop that **has a committed atlas** means real stock keyed to a shop that isn't in the town — exactly the failure I filed in R24, and one F's #7b would only catch at the *end* of a round, in a browser. It's now a node gate: for each godverse town, every `stock_godverse//` on disk whose id belongs to that cache **must be seated**, else FAIL. Subject-aware — SKIPs by name when a town has no atlas. **It already found something real, benign today:** redhill's lift drops keyed shop **2286 "Empire Revival"** (10 keyed in cache → 9 seated). It has no atlas, so nothing is orphaned and the gate passes. **→ G: if you stock Empire Revival at the beta, it will orphan** — the shop isn't on the plan. Monster Robot Party is safely seated (lot 8), which is the one that matters this round. ## Round 24 (2026-07-17) — v5.0-alpha: the identity field (ledger #2) ### → G: **`godverseShopId` is live — emit it, and mind the id-space trap** (the handshake for your #5) The field is published and the lift carries it: put `godverseShopId` on shop entries in a `source: "godverse+osm"` cache and it lands on `plan.shops[]`, which is what F's per-shop `base` reads. Contract in `web/assets/towns/README.md`; `validateTownCache` enforces it; selfcheck gates it. **The round doc says "the POS shop id IS the id — no mapping table". That's true for census shops and false for the shop this whole alpha is about** — and your own docstring is what told me: | shop | id source | equals `shop.id`? | |---|---|---| | census shops (`newtown_godverse`) | thriftgod `shop.id` (max 2992) | yes, incidentally | | **Monster Robot Party** | **dealgod store 3962749** — not in thriftgod's census | **no** | So I built it as an **opaque key**, not a census id: no equality assert, no derivation from `id`, no mapping table. Both spaces ride it, and your disjointness (2992 vs 3962749) is what makes that safe. **Uniqueness is an error**, because two shops keyed to one atlas is mis-stocking (charter risk #3). **⚠ The thing that will bite you in wave 2 — `redhill_godverse` cannot contain Monster Robot from either source. Measured, not inferred.** `godverse_town.py` takes **roads from the OSM donor** and **shops from thriftgod's census**. The shop is in neither: - **Not in the census** — your own R23 finding (that's *why* its id is a dealgod store id). - **Not in E's `redhill_real` donor** — I checked the cache the moment it landed: **36 shops, zero of type `record`, nothing matching /monster|robot/**. OSM's Red Hill doesn't know the shop exists. So the adapter as written produces a Red Hill of census charity shops, and the atlas at `stock_godverse/3962749/` keys to a shop that **isn't in the town** — F's #7b id-equality assertion would fail, correctly, at the end of the round. **Inject it explicitly**: name, `type: "record"`, its real lat/lon (147 Musgrave Rd), `godverseShopId: 3962749`. The schema is ready for it — that's exactly the case the opaque-key design exists to serve. **Second, cheaper trap on the same path:** `redhill_godverse` still has to clear **`MIN_TOWN_SHOPS` (6)** from the census bbox alone. Newtown yielded 18; Red Hill is a Brisbane suburb and thriftgod's census is thin there. If the bbox returns < 6, the cache is **rejected outright** (hard error, not a warn) and the town won't exist. I can't measure that from here — no census DB on m3 — but you'll see it instantly. If it bites, the count includes any shop you inject. **E's donor is otherwise good for you:** valid, no warnings, **73.3 m median spacing** — half the warn floor, a proper high street to hang the shop on. ### → F: the seam you need for #6a `plan.shops[].godverseShopId` is the per-shop `base` key — read it off the **plan**, not the cache. It's absent (not `undefined`) on every non-godverse shop, so `'godverseShopId' in shop` is a clean tier test: present ⇒ tier 1 candidate, absent ⇒ tier 0 parody. That absence is also why **no golden moved this round**. ### → Fable: one recorded departure from the ledger, and why Ledger #2 says `validateTownCache` **requires** the field on godverse shops. I implemented **missing → warn** (malformed and duplicate → hard errors). Three reasons, and it's yours to overrule: 1. **The charter's determinism boundary** — *world = seeded, frozen, gated; stock = data tiers*. An error inverts it: the **town** would fail to load because its **stock** identity is absent. Tier 0 must boot. 2. **Charter risk #3 names the remedy** — identity gaps "fail-soft to tier 0, never mis-stocked". Missing *is* the soft case; duplicate is the hard one. They're different failures and now get different arms. 3. **Sequencing** — `newtown_godverse` carries 0 ids today, it's G's to re-emit in wave 2, and I can't do it here (their namespace; no census DB on m3). A hard error would red the tree for C and E through all of wave 1 to say what the warn already says. The requirement still has teeth, in the place R23 taught us to put them: selfcheck **fails on partial keying** and prints an explicit **SKIP with the count** at zero — it cannot pass vacuously. ### Also: E's toowoomba retirement showed my spacing metric is gameable (worth knowing) E tried the re-bbox before retiring and found it **fixed my metric while making the town worse** — median NN 395.7 → 89.4 m ("passes") but hub density 3/12 → 2/12, the pack's worst. My warn is **necessary, not sufficient**: it catches scatter, and it can be satisfied by tightening the box around a scatter. D's hub test is the complement, and E used both. Retired pins removed from both golden maps (a golden for a deleted town can never fire — the vacuous-gate law one layer down). **Ballarat (255 m) is still flagged and still E's call this round; if it moves, I pin it.** ## Round 23 (2026-07-16) — v5.0-alpha: the spacing warn (D's thin-tail finding as law) ### → E: **the warn is live — and it flags TWO towns, not one** (the handshake for your #4) `validateTownCache` now warns when **`medianShopSpacing(cache) > 150 m`**. Import the metric from the barrel (`medianShopSpacing`, `MAX_MEDIAN_SPACING_M`) rather than re-deriving it — and if you re-implement in Python, the definition is: *per shop, distance to the nearest OTHER shop; take the median*, in equirectangular metres about `center.lat`. Warn, never error: **you curate, I only flag.** **Use the warn as your acceptance test.** Re-bbox toowoomba, re-run the validator; when the warn goes silent, the town has a high street. That's the whole loop, no judgement call needed. | town | median spacing | verdict | |---|---|---| | `toowoomba_real` | **396 m** | your ledger #4 — D proved it dead (1,417 patronage checks → 0 visits) | | `ballarat_real` | **255 m** | **nobody has looked at this one.** Same class, 1.7× inside the line | | braddon (worst passing) | 119 m | alive by D's hub test — 7 shops within 160 m, same as darwin | **Ballarat is the finding beyond the brief.** The round doc names only toowoomba, because D only spot-checked the two 12-shop towns. Measuring all 23 says ballarat is the same shape — and it's one of the three towns my R22 graded cluster fallback fired on (no venue candidate at the ≥3-shops/160 m floor), so two independent signals agree. Your call whether it re-bboxes or ships as a known thin-tail; counted either way. **If your fix moves toowoomba's (or ballarat's) cache, ping me — the goldens are mine to re-pin, and only what you move.** ### ⚠ The number in my validator will NOT match the number in D's notes — both are right D's notes say darwin **27 m**; my validator says darwin **68.3 m**. Neither is wrong: **D measured PLAN space** (post-lift metres), **the validator measures CACHE space** (raw lat/lon, before any lift exists — it has to, that's the layer it runs at). The lift marches shops at a regularised cadence, so it *compresses* dense towns and leaves sparse ones alone: | town | cache | plan | plan/cache | | |---|---|---|---|---| | darwin | 68.3 | 27.2 | **0.40** | dense ⇒ the lift compresses hard | | toowoomba | 396.1 | 386.8 | **0.98** | sparse ⇒ the lift barely moves it | | ballarat | 254.9 | 257.7 | **1.01** | sparse ⇒ ditto | That asymmetry is *why cache space is safe here*: the error is concentrated at the dense end, and the warn only ever fires at the sparse end, where the two spaces agree to within 2%. I verified all 23 towns in both spaces — **exactly one disagrees.** ### → G + Fable: `newtown_godverse` is the one town that flips (filed, not acted on) Godverse is the single cache where the two spaces straddle the line: **94.4 m cache (passes) → 158.3 m plan (would warn)**. My warn does **not** flag it, and I think that's correct on both counts: (a) no cache-space threshold can catch it without also flagging braddon, which D's own test says is alive; (b) godverse is a **census subset** — 18 thriftgod shops over the same Newtown footprint that carries 72 OSM shops — so its sparsity is *coverage*, not a bad bbox, and re-bboxing (the warn's whole remedy) cannot fix it. **But it's thin by two independent signals** — plan-space 158 m, and it's the third town my R22 cluster fallback fired on. Since R23 stocks a real record shop *in this town*, and the beta asks "fitzroy_godverse if the census is dense there", that's worth knowing now rather than at beta. Not my call — filing it. ## Round 22 (2026-07-16) — v4.0: the pack absorbed, 36 goldens pinned (the epoch close) ### → C + D + F: **the pack is in — 23 towns, all pinned, start your verifies** (the handshake) E's 17 new towns + Lane G's `newtown_godverse` fed through the lift. **All 36 waiting goldens pinned in this one commit** (`REAL_TOWN_GOLDENS` + `REAL_TOWN_GIG_GOLDENS` now hold **23 towns each**, zero unpinned). selfcheck **ALL GREEN 161,300/161,300**; scaffold + consistency GREEN; cross-process deterministic (pack-xor `0xca140954`). classic `0x3fa36874` / gig `0xb1d48ea1` / marched fixtures **frozen**. ### The pack absorbed — everything built this epoch held at 23-town scale, first try | | | |---|---| | towns | **23** (22 real + `newtown_godverse`) | | shops | 1,210 → **1,133 seated** (77 dropped, **6.4%**, all counted by cause) | | graph | **1 connected component on every town** (junction protection + cull/bridge) | | district | **3 venues on every town** (cluster bias + facade clearance) | **fitzroy (160 shops — 2× anything absorbed before)**: 139 seated, 1 component, 3 venues, 212 posters, all invariants green. The junction pipeline, fragmentation ruling, cluster bias, poster cap and capacity fence all ran at mecca scale with **no new hardening needed** — the pack needed absorbing, not fixing. ### One refinement this round: a graded cluster fallback for genuinely thin towns At pack scale three towns have **no candidate at the floor at all** — `ballarat`, `toowoomba`, `newtown_godverse` top out at **2** shops within 160 m. R21's bias switched off entirely there, stranding venues at 0. It now falls back to the **densest available**, so the venue still lands on what little crowd the town has: ballarat **0,2,0 → 2,2,2**, godverse **0,1,1 → 2,2,2**, toowoomba **1,0,0 → 1,2,1**. It only fires where the floor is unreachable ⇒ **no already-pinned town moved** (verified). **Acceptance (R21's, at 23-town scale): 20 of 23 towns have every venue ≥3 shops/160 m.** The three above sit at their town's genuine ceiling — the "no better candidate exists" case the acceptance carved out. **Saying so explicitly, as asked.** ### Drop outliers (counted, not hidden) `daylesford` **19%** (6/32, all overflow — 32 shops on a 171-road graph, the thinnest graph in the pack) and `fitzroy` **13%** (21/160, 17 overflow). Both are the R20 capacity fence doing its job: `RGAP` is already 0.5 m and `NODE_CLEAR` 3 m, so the only lever left is spilling overflow onto a *different* street — which buys a few percent by putting a shop on a road it isn't on. Same call as R20: **drop and count beats teleport**. Everything ≤6.4% pack-wide. ### → B / F (the tri diet, ledger #1): my side of the diagnosis My poster cap scales off **shop count**, so it auto-tracked the widening rather than fighting it (fitzroy 160 shops → 212 posters; katoomba 80 → 111). If B's layer-by-layer names **plan geometry** (lot or poster counts) as the `venue_night` driver, I own the fix and will take it. My read from the plan side: the growth is *more shops ⇒ more night building geometry at the venue block* — E's density landing as B's render cost — so I'd expect the driver to be buildings/LOD rather than posters. B's numbers decide it, not my expectation. **CLOSED — B's diagnosis landed (`0c4dad5`): the fix is not A's.** The driver is E's four furniture GLBs at **149,161 of 163,015 tris (91.5%)** ⇒ **asset tris ⇒ Lane E owns it** by the ledger's own rule. Plan geometry is nowhere near the bill: **posters 222 tris** (B: the cap "already worked and was never the lever"), **buildings 348** (the R3 atlas/instancing holding), venue pools 0. **A is clear on ledger #1 — F, don't wait on me for the re-measure.** Worth recording that B's numbers corrected my expectation too: I guessed buildings/LOD, and B measured it at 0.2%. B also found the real coupling — **shop count doesn't drive this, ROAD density does** (the bench/bin/shelter cadence runs on a 966-edge graph), which means my absorb of E's widening was never the cause either. The brief is a hypothesis; the measurement wins — mine included. ## Round 21 (2026-07-16) — the venue cluster bias (D's relocation finding) + goldens re-pinned ### → C + D: **cluster bias landed, goldens re-pinned — start your verifies** (the handshake) **Only the GIG goldens moved** (venue selection is gig-layer; the base town goldens are untouched): katoomba `0xbf586b77`, newtown `0xacdb3eee`, fremantle `0xcf3426fc`, bendigo `0x646b2c23`, castlemaine `0xf9713a69`. Base unchanged (katoomba `0x420f677d`, …). selfcheck **ALL GREEN 66419/66419**; classic `0x3fa36874` / gig `0xb1d48ea1` / marched fixtures **frozen**. ### → D: your relocation finding is resolved — and it was systemic You found katoomba's pub at 1 shop within 160 m. Measuring all five, **11 of 15 venues** sat under the threshold (newtown's were 0/0/1 — every venue stranded). After the bias, **0 of 15**: | town | before (shops within 160 m) | after | |---|---|---| | katoomba | pub **1**, rsl 10, band_room 7 | band_room 10, rsl 12, **pub 9** | | newtown | band_room 0, pub 0, rsl 1 | band_room 7, rsl 11, pub 5 | | fremantle | pub 1, rsl 2, band_room 2 | band_room 8, pub 3, rsl 3 | | bendigo | pub 1, rsl 0, band_room 0 | rsl 10, band_room 10, pub 5 | | castlemaine | band_room 6, rsl 4, pub 1 | pub 5, band_room 6, rsl 4 | Castlemaine cleared without needing the floor exception Fable flagged. Your venue-win % and the "gig with no passing crowd" observation should both improve — the pub is now in the retail heart on every town. ### How it works (and why synthetic is frozen BY CONSTRUCTION, not by luck) `pickVenues` builds a **cluster-adjacent field** first (candidates with ≥ `CLUSTER_MIN` 3 shops within `CLUSTER_R` 160 m), then the kind flavors (pub corner / band_room fringe / rsl off-spine) bias **within** that field rather than overriding it — a fringe band_room in the cluster beats a fringe band_room a kilometre from anyone. A town with no cluster falls back to the whole pool. **Gated on `plan.source === 'osm'`** — real-data towns only. Fable's brief assumed a blanket filter would be a no-op because "the synthetic town is one dense cluster anyway". **It isn't:** I measured **4 synthetic candidates per seed** sitting under the threshold (min cluster 0–1), so a blanket filter *would* have moved the frozen synthetic golden. The `source` gate makes it impossible instead of unlikely. The marched fixtures are 'osm' too but genuinely dense (min cluster 3/11/13) ⇒ verified no-op. ### FYI: E's v4.0 town pack is already booting clean `glebe_real`, `marrickville_real`, `newcastle_real` landed and my loader auto-validates + boots them through the full structural + gig suites — **all pass**, listed UNPINNED (a non-fatal warn). That's per the brief: **I pin the pack in R22**, and it must not gate the beta tag. Early signal: the pack needs no new hardening. ## Round 20 (2026-07-16) — v4.0-beta: the poster cap (density absorb pending E's widened caches) ### → F: **poster cap landed — re-measure `venue_night`** (your +965-tri edge should close) My R19 finding (438 posters on katoomba) is fixed. `gigs.js` now **caps the spine pole run at `POSTER_PER_SHOP × shops` (1.5/shop, floor 12)** — a seeded subsample. Real katoomba **438 → 31 posters** (~2× the synthetic feel), fremantle 32, castlemaine 14. Scaled by **shop count, not edge count**, so the `venue_night` stress view (was 200,965 tris, +0.5% over 200k) should drop well under. Re-measure and close it. ### Why no base-golden re-pin (and what I DID pin) Posters live in the **gig layer** (`withGigs`), not the base `plan_osm` output — so the pinned real-town **base** goldens are unchanged by the cap (correctly green). To regression-guard the capped output byte-exact (not just via `districtInvariants`), I added a **`REAL_TOWN_GIG_GOLDENS`** map (the full gig plan per town: district + capped posters) — katoomba `0xbd332e83`, etc. Synthetic gig golden `0xb1d48ea1` **frozen** — the cap never binds on the shop-dense synthetic town (493 shops ⇒ cap ~740 ≫ its ~15 posters). ### → all: **densities absorbed, goldens pinned** (ledger #2 — E's widened caches landed) E's five caches at **3–6× shops** (katoomba 20→80, fremantle 21→80, newtown 18→72, bendigo 9→36, castlemaine 6→24 = 292 total) are absorbed. **All five place 3 venues**; the poster cap auto-scaled off the live shop count exactly as designed (katoomba 111 posters at 80 shops). selfcheck **ALL GREEN 51866/51866**. - **Capacity widened** (the R18 overflow fence, load-tested). At 4× the dominant drop cause was **overflow** (an edge-side holds more shops than fit) — katoomba 10 of 11 drops. Tightened the run: `NODE_CLEAR` 6→3 and a **roads-only `RGAP` 2→0.5** (the shared marched `GAP` is untouched ⇒ marched fixtures stay frozen). Drops **7.9% → 5.5%** (16/292). 0.5 m also reads truer — a real AU main street is continuous terrace shopfronts. Residual drops stay **counted, now by cause** (`dropped_overflow` / `_overlap` / `_stranded`). I stopped there deliberately: the next lever is spilling overflow onto a *different* street, which buys ~1% at the cost of putting a shop on a road it isn't on. Dropping a crowded shop is the honest trade. - **Venue facade bias now covers `pub`.** R16 exempted pub ("spine-fronted, +Z always open") — true on synthetic/marched, but on a REAL graph at density the pub landed where its frontage poster overhung a cross street (newtown, −0.91 m). The `POSTER_CLEAR` filter now covers every venue kind; still a **no-op on synthetic** (every pub candidate already clears) so the gig golden `0xb1d48ea1` is frozen. - **Goldens re-pinned once** (base + gig, all five): katoomba `0x420f677d` / `0x2115732a`, newtown `0x741c18ec` / `0x17ab31bb`, fremantle `0x73d385df` / `0x82c27397`, bendigo `0xc175194e` / `0xf5ce58c4`, castlemaine `0x26c128c9` / `0x48272da9`. classic/gig/marched fixtures **frozen**. - **Fixed:** my real-cache loader globbed `*.json` and tried to validate E's new `index.json` as a town cache. It now skips it (it's B's selector index, not a cache). ## Round 19 (2026-07-16) — the alpha close: fragmentation resolved + findings hardened + goldens pinned ### → B / C / D / F: **`katoomba_real` boots on real roads, golden pinned, findings resolved** (the handshake) The A→E→A finalization landed. `katoomba_real` golden = **`0xb7ffbca2`** (pinned); the other four real towns pinned too (newtown `0x6f984c81`, fremantle `0xfb197000`, bendigo `0x21179b3b`, castlemaine `0x7c47f639`). selfcheck **ALL GREEN 51670/51670**; classic/gig/marched-fixtures frozen (`0x3fa36874` / `0xb1d48ea1` / …). ### The fragmentation fix (D's finding — Fable's ruling, my details) **katoomba: 105 components → 1; 58% → 100% of street-metres in the main net.** Two mechanisms: 1. **Junction-protected Douglas–Peucker** (the root cause). A point shared by ≥2 ways is a junction; simplify only *between* junctions, never through one — the collinear junction-drop where a side street tees into a straight main road. This alone took every town to 94–100% main-net. 2. **Cull/bridge** (`JOIN_TOL` 12 m, shop-island bridge ≤ 200 m): main component + joined near-fragments; far **shopless** islands culled (bbox-clipped noise + dead streaming budget = half of B's tris problem). Per-town: katoomba 2 islands culled/0 bridges, newtown 3/4, fremantle 4/0, bendigo 3/1, castlemaine 0/1. **Every dropped shop is counted** in `norm.dropped` (ruling): katoomba kept 19/20 (1 overflow/overlap crowd drop — the R18 overflow prediction, now measured + counted), the rest kept all. ### → D: your re-measure inputs (post-fix) Component count → **1** (all towns). Street-metres in main net → **100%**. Stranded shops → **0** (all connected or counted-dropped). Near-crowd on fragments should collapse to ~0 (one component). The crafted irregular-town scaffold still runs; the real katoomba golden is pinned so your reruns are stable. ### → F: E's poster finding resolved Spine posters that overhang a **real crossing carriageway** are now skipped (measured exactly as the selfcheck kerb floor). No-op on synthetic (gig golden frozen); the 23 real-town clearance failures → 0. ### New finding (v4.0-beta, non-blocking): spine-poster density scales with edges Real katoomba emits **438 posters** (2 per main edge × a dense real graph) vs ~14 synthetic. Draws stay low (instanced per gigId, B measured ~1 draw), and every poster clears its kerb — but 438 pole-posters town-wide reads busy. **Beta candidate:** cap posters-per-town (seeded subsample of the spine run). Not fixed this round — invariants hold, it's density polish. ## Round 18 (2026-07-16) — v4.0-alpha REAL ROADS: cache schema v2 (roads[]) + the real-graph lift ### → E: **schema v2 is live (committed first) — fetch roads to it** A v2 cache adds `roads: [ { kind, pts:[[lat,lon],…], id?, name? } ]` (`kind` = OSM `highway=*`). Full contract in `web/assets/towns/README.md`; `validateTownCache` enforces it (schema `…/1` still valid → marched). **`katoomba_real` first** (the alpha gate). Bound the `highway=*` query to the same per-town bbox as the shops. Snap tolerance is 3 m, so ship intersections as **shared coordinates** where ways meet (OSM already does) — that's how the lift finds real intersections. Drop each cache in; the selfcheck auto-builds + validates the real graph and prints the golden. Ping me → I pin `katoomba_real`'s real-roads golden. ### The graph lift (`plan_osm.buildRealRoads`) — implemented + proven on a synthetic irregular graph Real OSM ways → the CityPlan street graph, **same schema out** (B/C/D/F unchanged by contract). Pipeline: project → simplify (Douglas–Peucker ε≈6 m) → snap coincident points (≤3 m) to shared nodes = real intersections → straight-segment edges → classify `main` by OSM class + shop density → seat each shop on its **nearest real edge** (real order, real side), marched, overlap-resolved. Passes the **full structuralSuite + gig district + determinism** on a synthetic town with a main street, two cross streets (a real intersection + an acute angle), a dead-end lane and a curve. `roads` absent ⇒ the v1 marched fallback — **all osm goldens frozen** (melbourne/katoomba/silverton), classic/gig frozen. NOT YET run against real katoomba geometry — that's the A→E→A finalization (E fetches, I pin + harden). ### Fidelity decision (charter risk #4 — A owns the knob) + the alpha failure list I chose **real edge + real order + real side, regularised spacing** — NOT pixel-exact shop positions, because real shops overlap and the no-overlap invariant is load-bearing (venues, chunk streaming, sim all assume it). Written down for the alpha (fixed or fenced): 1. **Overflow drop.** A short real edge can't seat all its shops (marched at footprint+gap); overflow shops drop (real order kept). `norm.dropped` counts them. Katoomba (20 shops, real street lengths) should keep all — verify when E's roads land. *Fence if it bites: widen edge capacity or split long shop runs.* 2. **Intersection topology = shared coordinates only.** Two ways that cross WITHOUT a shared OSM node become topologically-disconnected edges (visually crossing, not graph-connected). **→ D:** the sim graph may not route across such crossings; if Katoomba has any, that's the risk to measure. E ships shared coords at junctions (OSM convention) so most are fine. 3. **Blocks are per-edge frontage strips**, not planar faces. **→ B:** your ground/pavement render reads these polys; a per-edge strip may look thin/overlapping at a tight corner — audit + file with a measurement (it's my lot geometry to fix, not yours). 4. **Overlap-resolve drops crowded-intersection lots** (deterministic, by shop id). Rare; counted in `norm.dropped`. ### → B/C/D: handshake state `katoomba_real` still MARCHES today (it's v1 — E hasn't fetched roads yet), so I can't yet say "boots with real roads, golden pinned." I'll fire that handshake the moment E's katoomba roads land and I pin the golden. Per Fable's note, your audit prep (scaffolding, assertion lists) isn't blocked on me — the graph lift + the failure list above tell you exactly what irregular geometry to point your audits at. ## Round 17 (2026-07-16) — the town-cache contract (published FIRST) + plan_osm hardened for real data ### → E: **the town-cache contract is live — build `build_towns.py` to it** (half-day handshake) Your pipeline outputs one JSON per real town in the processed shape; `plan_osm` marches it like the fixtures. **Home: `web/assets/towns/.json`** (full spec in that dir's `README.md`). Shape: ``` { schema:"procity-town-cache/1", key, town, source:"osm", license, attribution, center:{lat,lon}, shops:[ {id, name, type, lat, lon, suburb?} ] } // + optional bbox/counts/fetchedAt ``` - **Authoritative validator:** `validateTownCache(cache)` in `plan_osm.js` — `{ok, errors[], warnings[]}`. Import it; don't hand-roll the check. **Hard errors:** finite `center.{lat,lon}`, `shops` ≥ **6** (`MIN_TOWN_SHOPS`), finite shop `lat`/`lon`. **Warnings (absorbed):** blank name → type label, unknown `type` → `opshop`, dup ids, **span > 5 km**. - `type` should be a registry `SHOP_TYPE`; map OSM `shop=*` tags to those (unknowns → `opshop`). - **Katoomba-real:** use a DISTINCT key (`katoomba_real`, not `katoomba`) so it sits alongside the hand-made fixture for the comparison — `osmTownKeys()` dedupes but same-key would shadow the fixture. - Drop each cache in and the selfcheck auto-validates + boots + prints its golden to pin; ping me (E→A handshake) and I'll pin `REAL_TOWN_GOLDENS` + it enters the sweep. - **ODbL:** ship `SOURCES.md` + the `license`/`attribution` fields; cache raw Overpass so re-runs don't refetch. ### plan_osm hardening — verified against real data (2,828 real Canberra shops) I ran thriftgod's real `city_source.json` through the hardened `plan_osm`: **it boots a valid plan** (0 bad-coord lots, 0 "undefined" names). The marching regularises messy real coords, so dup/odd positions are a non-issue. Added: blank-name → registry-label default; the contract validator gates the rest. ### → Fable / v4 charter — the ugly-real-geometry RISK LIST (the scout's real finding) 1. **The mega-strip (the big one).** An unbounded Overpass bbox = a whole region. All 2,828 Canberra shops marched into an **11.6 km** single strip (350 shops/avenue) — a valid plan, an unusable "town". **Mitigation shipped:** `validateTownCache` warns at span > 5 km; the contract says a cache is ONE compact town. **v4 risk:** town SELECTION (bbox per town) is E's pipeline's job and the real quality lever — the generator can't rescue an over-broad query. Charter needs a per-town bbox/centre discipline. 2. **Sparse compact towns.** A tight 2 km radius of inner Canberra held only 6 secondhand shops — real small towns can fall below a usable density. `MIN_TOWN_SHOPS` is 6 (the functional floor for the district); E should curate towns with real shopping density (Katoomba 19, Melbourne 95 clear it easily). 3. **Not a risk (settled):** osm facades face `−Z`/south with rows a fixed 54 m apart, so the R16 `+Z` clearance bias is a no-op on osm towns (facades always clear) — real towns won't trip the frontage floor. ### Scope held: no new plan features, no golden moved Classic `0x3fa36874`, gig `0xb1d48ea1`, all three osm fixtures frozen. selfcheck **15277/15277** (added the contract stress suite + the ready-and-empty real-cache path). The scout proved the *existing* contract eats real data — nothing more, per the fence. ## Round 16 (2026-07-16) — corner-poster fix via pickVenues clearance bias (ledger #6) + no-spine osm fixture (#7) ### → B + F: **new gig golden `0x4f4a549d → 0xb1d48ea1`** (classic/synthetic `0x3fa36874` unchanged) The round's one sanctioned move. **F**: the default-boot gate pins to `0xb1d48ea1` (not the R15 value the brief cited). **B**: re-shoot against this. Amendment law; §v3 marker records it. ### The corner-poster fix (NOT a nudge — Fable's brief premise was wrong) The R15 finding was that a corner venue's frontage poster sits near a crossing kerb. The brief proposed nudging the poster ALONG the facade away from the crossing-street *end* — but I proved that's geometrically impossible: the crossing edge is **parallel** to the facade and spans it (0/187 along-facade slides recover any clearance). Root cause: band_room/rsl land on **through-lots on narrow warehouse-fringe blocks**, so the `+Z` wall (B's door side, where the poster seats) faces the block's *other* street, overlapping its carriageway by up to ~3 m. **Fable's ruling (asked mid-round): bias `pickVenues`.** So: - `gigs.js pickVenues` now prefers a band_room/rsl candidate whose `+Z` facade clears every carriageway by **≥ `POSTER_CLEAR` (0.5 m)**; if the warehouse fringe is uniformly narrow it broadens to any off-spine clear lot before falling back. Proven: **0 of 851** frontage posters across 400 seeds now sit below the floor (worst clearance 0.51 m). Pub is untouched (spine-fronted, `+Z` open — the hero shot doesn't move). - The R15 blanket frontage *exemption* in the selfcheck is now a real **floor**: frontage posters must clear their kerb by ≥ `POSTER_CLEAR`; spine posters keep the ≥ 0 no-bitumen floor. `POSTER_CLEAR` is exported from the barrel — import it, don't re-guess. (`−0.02` in the assert absorbs r2 poster-coord rounding.) ### The no-spine osm fixture (ledger #7): `silverton` `osm_fixture.js` gains **`silverton`** — a *constructed* single-row town (all shops at one latitude ⇒ plan_osm bins them into one avenue ⇒ **0 `main` edges**). Closes the R14 sweep gap (both real fixtures had a spine): it proves `pickVenues`' no-spine branch (pub's `onMain` filter is empty) and `buildPosters`' no-main fallback (spine run over every edge). Base golden pinned `0xd4b351c9`; the sweep asserts 0 main edges + graceful 2–4-venue placement. `osmTownKeys()` now returns 3 towns — anything iterating it (shell selector, your gates) picks it up; it's a valid town, `?plansrc=osm&town=silverton`. ### → F: the classic-law / flags-table §v3 amendment is yours I amended §v3 for the golden + poster bias only (localized, recorded in the FROZEN marker). The v3.1 flip (default-on, `?classic=1` covenant, the flags table) is your §v3 amendment — I left the prime-flag-law blockquote untouched to avoid racing you on the same lines. My classic golden (`0x3fa36874`) is your `?classic=1` regression target; my gig golden (`0xb1d48ea1`) is your default-boot target. ## Round 15 (2026-07-16) — the frontage-poster +Z flip (ledger #1) + the one sanctioned golden move ### → B + F: **new gig golden `0x1f636349 → 0x4f4a549d`** (synthetic `0x3fa36874` unchanged) The frontage-poster fix moved `plan.posters`, so the gig golden re-pinned — the round's ONE sanctioned move (amendment law; §v3 marker records it). B: re-shoot `queue_night` against this. F: the smoke's gig golden is now `0x4f4a549d`. ### The fix (gigs.js buildPosters item 1) Each venue's own-frontage poster sat at local **−Z** — the *back* of the building (~16 m behind, matching B's 15.9 m), invisible from the street, which is why `queue_night` framed no poster. Flipped to the **+Z street facade** AND turned it to read from the street: `ry = lot.ry + π` so its printed face points `+Z` at the queue-side camera (else `DoubleSide` shows a mirrored band name). Proven: pub + rsl frontage posters now sit **0.06 m from B's door**, printed-face·(+Z street) = **1.000**. Only item 1; spine posters untouched. ### The −Z/+Z convention, settled (worth recording — it's the R13 RY_FLIP π-ambiguity again) The **visible** venue face is `+Z`: `buildings.js` builds the facade/door at `zf = +d/2` ("front face at local +Z"), and `venue.js` seats the door-glow/queue/poster-camera on `(sin ry, cos ry)` = `+Z`. The selfcheck's `facing = (−sin ry,−cos ry)` "every lot faces its frontEdge" is the **sim/GLB** convention, 180° off from the visual front (same ambiguity D fixed at the source for the fleet in R13). A poster faces the *viewer* when its printed face = `(−sin ry,−cos ry)` points at them, i.e. `ry = atan2(away-from-viewer)`. ### → B + F (eyeball in the tour): corner-venue frontage posters can sit near a crossing street The `+Z` facade is the block-interior side in *plan* coordinates, so for a **corner venue** (a band_room/ rsl fronting one street with another behind — e.g. seed 1 band_room lot#481, 1.58 m from a side-street centreline) the frontage poster lands near that crossing street's kerb. That's B's building geometry (the door is there too), not a mid-road defect — so the selfcheck's no-bitumen assert now **exempts frontage posters** (they're building-bound) and keeps it for free-standing **spine** posters. The pub hero is clean (29.9 m of open ground on `+Z`). If a corner poster reads as floating over a road in the tour, that's a v3.1 venue-placement tweak, not a v3.0 blocker. ## Round 14 (2026-07-16) — v3.0 RELEASE: invariant sweep + CITY_SPEC §v3 frozen Release round — no new systems, no golden moves. **Synthetic `0x3fa36874` and gig `0x1f636349` are unchanged**; the only code I touched is `selfcheck.js` (test-only), so no seeded stream gained a draw. ### The 400-seed district sweep (`selfcheck.js`, ALL GREEN 14264/14264 in ~0.7s) - Factored the district-contract checks out of `gigSuite` into `districtInvariants(plan, label)` so the sweep asserts them at SCALE without re-running the heavy `structuralSuite` (that still runs on the 6 hero `SEEDS` + osm parity). Behaviour-preserving — the 4 hero-seed `gigSuite` runs are byte-identical. - Sweeps **seeds 1–400** synthetic + the large edge seeds (2e9, 8675309) asserting the full district contract each: 2–4 venues, ≥1 pub, never the openLate shop, no band twice in one night town-wide, per-venue 4–6 nights, every poster off the carriageway. Also asserts the seeded count **spans {2,3,4}** across the range (measured 146/121/133) — proves the 2–4 range is genuinely exercised, not pinned. - **osm graceful placement**: `katoomba` (smallest fixture, 19 shops, **no warehouse district**) still lands 2–4 valid venues incl. a pub for every hero seed — proving `pickVenues`' off-spine fallback. Honest scope: both osm fixtures keep a main spine, so this proves the *no-warehouse* branch, not *no-spine* (a genuine no-spine town would need a single-row fixture that doesn't exist yet — v3.1). - Poster check kept as "≥ `roadWidth/2` from the nearest edge centreline" (not a literal `poleOffset` compare, which would false-fail legit corner posters seated behind their placement edge's `poleOffset` but inside a crossing edge's clearance band). Added a `−1e-6` epsilon on the strict `<` to immunise the exact kerb-line boundary against r2 rounding (only reachable on a no-main-spine fallback; not hit today). ### CITY_SPEC §v3 frozen at v3.0 Final wording pass; marked **FROZEN at `v3.0` (round 14)** with a §v2-style marker. Venues table, genre map, gigKey contract, corridor law, and gig golden `0x1f636349` are all final (genres are pubrock/grunge/ covers — Fable's R14 ruling, John did not re-flavour). ### → Lane F: the alias-retirement handshake (R13 debt #1) — I deliberately did NOT touch it The R12 single-venue compat alias is **still live** (`gig_state.js:141-151`, read by `audio.js`/`venue.js`), so I left the §v3 alias sentences (`CITY_SPEC.md` ~lines 271–272) **intact** — deleting them at freeze time would be doc-ahead-of-code and would race you on the same lines. The freeze marker explicitly carves this out as your pending edit. In your alias-retirement commit (after B drops its readers), delete both `gig_state.js:141-151` and those two spec sentences **together** — one atomic step, one deleter, no race. - While you're in that runtime block: it enumerates `stateOf/onOf/openOf/gigOf/coverOf/bandNameOf/paidOf` + `markPaidOf` + `.venueShopIds`, but **omits `nightOf(id)`** (`gig_state.js:131`). Add it when you edit. ### → Lane B: second edit site for the bare-string arm When you remove the `venue.update()` bare-string alias arm, note `venue.js:228` also self-calls `update('quiet')` at construction through that same arm — fix both or it falls through (harmless today: still yields `'quiet'`, but it's a live second site). ## Round 13 (2026-07-15) — v3.0-beta THE DISTRICT (published FIRST; C/D/B/F hang off this) Supersedes the R12 alpha interface below. Same flag (`?gigs=1`), same prime-flag law: flag OFF ⇒ plan byte-identical to v2 (synthetic golden still `0x3fa36874`, osm unchanged). What changed: **one venue → 2–4; one genre → three (by kind); one night's posters → town-wide.** The gig **entry shapes are unchanged** — downstream code that read one venue now iterates many. Verified green: `selfcheck.js` 3273/3273, `scaffold_check` + `consistency` GREEN. **Gig golden re-pinned: `0xa6ae5a5e` → `0x1f636349`** (the district changed gig-plan output; re-pinned in the same commit as the change per CITY_SPEC amendment law). Turn-on is unchanged: `generatePlanFor(seed, src, { town, gigs:true, customBands })`. ### The venues (NEW: 2–4 per town, three kinds) — `plan.shops.filter(s => s.venue)` Each converted-in-place venue shop carries three new fields: ``` s.venue === true · s.venueKind ∈ {'pub','band_room','rsl'} · s.genreKey ∈ {'pubrock','grunge','covers'} s.type === s.venueKind // C keys its interior recipe off shop.type — the type IS the kind ``` | kind | genreKey | placement | hours | reads as | |---|---|---|---|---| | `pub` | `pubrock` | spine end/corner | `[17,23]` | ≥2-storey corner hotel | | `band_room` | `grunge` | warehouse fringe | `[18,23]` | single-storey tin shed | | `rsl` | `covers` | off-spine | `[16,23]` | carpet-and-bistro club | - Seeded count 2–4; a 4th venue is a **second pub** at the far spine end. A town always has ≥1 pub. - **Never** the openLate landmark (still exactly one openLate shop). Placement degrades gracefully so osm towns (no warehouse district, maybe no spine) still get their venues. - `genreKey` comes from the registry kind→genre map: `genreForVenueKind(kind)` / `SHOP_TYPES[kind].genre`. ### THE gigKey CONTRACT (debt #1 — import it, don't build the string) The audio-manifest bed key **IS** the gigKey and is **always** `gig-`. No mapping table anywhere: ```js import { gigKeyFor } from './citygen/index.js'; // re-exported from the barrel gigKeyFor('pubrock') === 'gig-pubrock' // C sets audio.gigKey = gigKeyFor(genreKey); E names the bed the same; B reads it; F resolves it ``` R12 lost a bed to a private rename (`gig-pubrock` vs `pubrock-live`) — audio fails soft, so the band just mimed. One key, zero mapping. E renames `pubrock-live → gig-pubrock` this round; F deletes the `GIG_BED` bridge. ### `plan.gigs` — the week across the whole district (entry shape UNCHANGED) ``` plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ] ``` - **7 nights × every venue, but not every venue plays every night**: each venue plays a seeded **4–6** of the 7, dark the rest (reads true). `gigId` unique **town-wide**; `night ∈ [0,7)`; `genreKey ≡` its venue's. - **Pick tonight per venue** (each venue has its own state now): `plan.gigs.filter(g => g.venueShopId === v.id && g.night === 0)[0]` — **may be undefined** (venue dark tonight; F/B skip it, no poster). - `startSeg=5 endSeg=5`. Same `quiet → doors(seg4) → on(seg5) → done` machine — F runs one **per venue**. - `cover` ∈ `{0}∪[2,10]`, skewed by kind: pub 50% free/`$2–10`, RSL 45% free/`$2–5`, band_room 70% free/`$2–6`. F debits **per venue** — a night at two venues is two covers. - **No band plays two venues on one night** (guaranteed by A). Custom names spread night-major across the whole week town-wide (deduped, priority-seeded), the rest generated. ### `plan.posters` — town-wide now (shape UNCHANGED; **Lane B** renders) ``` plan.posters = [ { id, gigId, x, z, ry }, … ] // only TONIGHT's (night 0) playing venues advertised ``` - One on each playing venue's **own frontage** (on the facade plane) + a seeded spine run (2 per main edge). - Different venues' posters share the same pole runs — that IS the district reading. `p.gigId` tells B/F which venue's gig each poster sells (`plan.gigs.find(g=>g.gigId===p.gigId)`). - **Debt #5 fixed:** posters no longer land mid-road. See the corridor law ↓. ### The street-corridor law (debt #5) — NEW helpers, use for ANY street furniture `edge.width` is the **full corridor** (carriageway + verge, centreline ± width/2), NOT the carriageway. `node.x + 2` seats furniture on the bitumen. Registry now exports the split (re-exported from the barrel): ```js import { roadWidth, vergeBand, poleOffset } from './citygen/index.js'; roadWidth(edge) // carriageway half-corridor (main 10m, side 6–8, lane=all, arcade 0) vergeBand(edge) // [inner,outer] — where footpath furniture belongs poleOffset(edge) // where a pole/poster/post seats: just behind the kerb ``` Selfcheck now **asserts** no poster stands within `roadWidth/2` of any edge centreline. ### → Lane C: two recipes to add + the gigKey + a corrected field - `getRecipe(shop.type)` resolves off `RECIPES[type]` — `RECIPES.pub` exists; **`RECIPES.band_room` and `RECIPES.rsl` do NOT yet.** Until you add them, those venues fall back to the op-shop interior. That's the handshake — you're next in order. - Set `room.audio.gigKey = gigKeyFor(shop.genreKey)` (import from the barrel) — never hand-build `'gig-…'`. - Registry `pub.interior` was `'band_room'` (harmless, unread — you key off `shop.type` via `canonicalType`); I corrected it to `'pub'` so it isn't a name-collision with the new `band_room` venue type. No behaviour change. ### → Lane D: iterate the venues, one setGig each `const venues = plan.shops.filter(s => s.venue)` — call `setGig` **per venue** (multiple concurrent). Tonight's gig per venue as above (skip dark ones). `isOpen(shopOrHours, hour)` unchanged (half-open law). Queue zone comes from B (`venue.queueZone`) — consume if present, else a straight line off the door. ### → Lane F: state machine goes per-venue One latch **per venue keyed by `venueShopId`**, off the same `procity:segment` spine. `gigKeyFor(genreKey)` resolves all three genres (the debt-#1 assert now covers 3). Delete `GIG_BED` once E's rename lands. Cover charge is per venue (per-venue paid stamps). Keep the alpha `window.PROCITY.gigs` shape as a compat alias for one round (B reads it mid-round). ## Round 12 (2026-07-15) — v3.0-alpha GIG INTERFACE (published; C/D/B/F build off this) Everything is behind `?gigs=1` (prime flag law): with the flag OFF the plan is **byte-identical** to v2 (all goldens frozen — synthetic `0x3fa36874`, osm unchanged). The gig layer is a post-hoc augmentation applied by the selector, so it works on synthetic AND osm towns. ### How the gig layer turns on (Lane F wires this) ```js // F's shell bootstrap, when ?gigs=1: const customBands = await loadCustomBands(); // see drop-in below; [] under ?noassets or if absent const plan = citygen.generatePlanFor(seed, src, { town, gigs: true, customBands }); ``` `generatePlanFor(seed, source, { gigs:true, customBands })` → base plan + `withGigs`. Without `gigs:true`, the base plan is returned untouched. `generatePlanFor` stays **synchronous** — F does the async `custom_bands.json` fetch in the bootstrap and passes the array in. ### `plan.gigs` — the nightly schedule (NEW, present only when gigs on) ``` plan.gigs = [ { gigId, venueShopId, bandName, genreKey, night, startSeg, endSeg, cover }, … ] ``` - 7 nightly entries at the one venue. `night` = 0-based index; **F picks tonight's** (alpha: `gigs[0]`). - `venueShopId` = the pub shop's id. `genreKey` = `'pubrock'` (alpha's one genre; E ships that bed). - `startSeg=5` (NIGHT, 22:00) `endSeg=5`. F's state machine: `quiet → doors (startSeg−1 = DUSK seg4) → on (seg5) → done`. Segment→hour map is lighting.js (`0 DAWN … 5 NIGHT`). - `cover`: integer. **~half are 0 (free, walk in); the rest $2–$10** (F debits the wallet at the door). ### The venue (one `pub` per town) - A spine-END plain shop is converted in place to `type:'pub'` (keeps its lot id + geometry ⇒ no overlap, enterable like any shop). Tagged **`shop.venue === true`**; also `plan.shops.find(s=>s.type==='pub')`. - **Never** the openLate landmark (the video survives — the town still has exactly one openLate shop). - Registry `pub` type added: facades, `interior:'band_room'` (→ **Lane C** builds the pub archetype off this), fittings hints, hours `[17,23]`. It has **no district weights** so it's never auto-placed. - Venue hours `[17,23]` are open through DUSK+NIGHT so the gig runs and **D's patronage surge** fills it. ### `plan.posters` — gig advertising (NEW; **Lane B** renders) ``` plan.posters = [ { id, gigId, x, z, ry }, … ] // ~6, advertising tonight's gig (gigs[0]) ``` One on the venue frontage + a seeded few along the spine, on the poles/walls B already streams. B places E's poster skin at `(x,z)` rotated `ry`. F wires "poster → tonight's-gig discoverability". ### → Lane D: the closing-time debt is PAID — consume `isOpen` `citygen.isOpen(shopOrHours, hour)` is the canonical **half-open** law (`open ≤ hour < close`) — the frozen closing-time ruling as code. Use it for the gig-night patronage surge: the venue is open through the gig, patrons enter while open, and at close they **drain** (finish their dwell) — never popped, and the player is never force-ejected. Your `sim.js _openAt` already matches; switch to `isOpen` so there's one source of truth (or keep yours — they're identical). ### → John: the band-name drop-in (`web/assets/custom_bands.json`) Edit the file, reload — your names get **priority** on the town's posters. Schema: `{ "bands": ["The Feral Galahs", …] }`. **Delete it and the generator takes over** (no errors, no fetch under `?noassets=1`). Deterministic given (seed, file contents). An example ships with 10 names; a town has 7 nightly slots, so up to 7 of yours surface (shuffled by seed). ## Round 9 (2026-07-15) ### → Lane D/F: closing-time occupant ruling (you already built it right — this ratifies + freezes it) Question: what happens to a ped browsing inside a shop when it hits closing time? **Ruling** (now a frozen contract law — CITY_SPEC §"CityPlan v2 → Closing-time occupant ruling"): 1. **No new entry when closed.** Openness is half-open: `open ≤ h < close` (a shop closing at 17 is shut at 17:00). Patronage only routes into an *open* shop; the player gets the CLOSED plate. ✓ your `sim.js _openAt` + `_nearestOpenShop` already do exactly this. 2. **Occupants drain, they don't pop.** A ped inside at close finishes its bounded 5–20s dwell and re-emerges normally — **never** teleported/culled at the close instant. `occupancyOf(shopId)` falls to 0 within one dwell window on its own, so browser rigs (the C→D→F seam) clear with the same drain. ✓ your `patronTimer`/`_emerge` already do this — do NOT add a hard close-time eviction. 3. **Player is never force-ejected** — closing locks *entry* only; a player inside stays until they leave. 4. **The openLate video shop** is the one place still filling after ~21:00; at its own close it drains identically. All seeded + clock-driven ⇒ deterministic occupancy. No code change requested of you — this pins the behavior so it can't drift, and documents it for v3. ### → everyone / F: the CityPlan v2 producer contract is FROZEN CITY_SPEC now has a **"CityPlan v2 — frozen producer contract"** section: producer API, the goldens table (synthetic `0x3fa36874`, osm/melbourne `0x34cfdec0`, osm/katoomba `0x0f652510`), the town-key mechanism, osm normalization rules, and the hours/openLate/storeys/closing laws — all matching code (F: diff away). Changes to Layer-1 now require a CITY_SPEC amendment + golden re-pin in the same commit. ## Round 8 (2026-07-15) ### → Lane F: OSM is at parity + there's a SECOND town. Three goldens to pin, one new selector arg. - **Selfcheck parity done**: the full invariant suite (`structuralSuite`) now runs on **every (source, town)** — synthetic and both OSM towns get identical coverage (storeys, one-shop-per-lot, facing, within/cross-block overlap, corridor coverage, determinism, exactly-one-openLate, …). `node selfcheck.js` → **ALL GREEN 1727/1727**. - **Selector gained a town arg** (`web/js/citygen/index.js`): `generatePlanFor(seed, source, { town })`. Shell wiring: read `&town`, pass it through — `generatePlanFor(seed, src, { town: q.get('town') || undefined })`. `map.html` is the reference impl (`?plansrc=osm&town=katoomba`). Unknown/absent town → the default (`melbourne`). `osmTownKeys()` lists the available towns for a UI picker. - **THREE goldens to pin** (seed 20261990), all asserted by selfcheck already: | source / town | golden | |--------------------|--------------| | synthetic | `0x3fa36874` | | osm / melbourne | `0x34cfdec0` | | **osm / katoomba** | `0x0f652510` | The determinism gate should key on **(seed, plansrc, town)**. Synthetic + melbourne goldens are **unchanged** from round 6. - Katoomba (Blue Mountains, 19 shops — op-shop/book heavy) is a deliberate small-regional contrast to inner-Melbourne (95, book heavy). Both boot end-to-end; screenshots in `docs/shots/laneA/`. ### Normalization: the importer bends the DATA to the contract, never the contract (round-8 item 4) Real OSM data that fights a CityPlan contract is normalized deterministically, and the change is **logged** (selfcheck prints a per-town line; `generatePlanOSM(seed, town, { report })` fills `report`): - unknown OSM `shop=` kind → `opshop` (`typesRemapped`); - a shop closing ≥22:00 → clamped to 21:00 so only the one openLate landmark is late (`hoursClamped`); - openLate landmark prefers a **video**; a town with no video falls back to another non-stall type and `report.openLate` records it (both Melbourne + Katoomba have video, so both read `video`). ### RECIPE — add another OSM town (mechanical, ~10 min) 1. **Pick a cluster** in thriftgod's `city_source.json` (Overpass cache). Find a centroid + a cell size that captures one coherent town (aim 15–150 on-theme shops). The extraction one-liner used for melbourne/katoomba is in `A-progress.md` (round 6/8) — reuse it with your `(cLat,cLon,cell)`. 2. **Append the town** to `OSM_TOWNS` in `web/js/citygen/osm_fixture.js`: `key: { town, source, center:{lat,lon}, shops:[{id,name,type,lat,lon,suburb}, …] }` — sort shops by `id` (determinism). Map OSM `shop=` kinds → registry types (charity→opshop, books→book, music→record, toys→toy, video_games→video, antiques/second_hand→opshop/pawn). 3. **Pin its golden**: run `node web/js/citygen/selfcheck.js`. The unpinned town prints a ready-to-paste line — `⚠ osm/: UNPINNED — add to OSM_GOLDENS → : 0x…,` — paste it into `OSM_GOLDENS` in `selfcheck.js`, re-run → green. (The full parity suite already ran on the town; only the golden needed pinning.) 4. **Tell F** the new (seed, plansrc, town) hash for the determinism gate. Done — zero network, the full parity suite covers the town automatically. *Dry-run (round 9): verified end-to-end with a throwaway Newtown (42 shops) — the parity suite auto-ran (1931/1932, only the golden unpinned) and selfcheck printed `newtown: 0x9045e577,`. Reverted; no 3rd town shipped (didn't fall out "for free"). The recipe is mechanical as written.* ## Round 6 (2026-07-14) ### → Lane F (F2): the `?plansrc=osm` plan-source seam is LANDED — wire + pin The second plan producer is live behind the same CityPlan contract. Full seam, invariants green (not a partial). What F needs to wire the shell bootstrap + extend the determinism gate: - **Selector API** (`web/js/citygen/index.js`): `generatePlanFor(seed, source)` — `source==='osm'` → real-data importer, anything else → the byte-identical synthetic generator. Also exported: `generatePlanOSM(seed)`, `generatePlan(seed)` (synthetic, unchanged). Unknown source falls back to synthetic. - **Shell wiring** (in `web/index.html`, F-owned — one line): read `?plansrc`, then `const src = new URLSearchParams(location.search).get('plansrc')==='osm' ? 'osm' : 'synthetic';` and call `citygen.generatePlanFor(seed, src)` where it currently calls `generatePlan(seed)`. `map.html` already does exactly this (reference impl). - **Determinism gate**: pin BOTH goldens, keyed by (seed, plansrc), seed 20261990: - synthetic `0x3fa36874` (unchanged this round) - **osm `0x34cfdec0`** (`xmur3(JSON.stringify(generatePlanOSM(20261990)))`) `selfcheck.js` already asserts both; the smoke can just hash-compare. - **Zero network**: the fixture is `web/js/citygen/osm_fixture.js` (95 real inner-Melbourne shops from thriftgod's Overpass cache), imported as a JS module — no fetch/XHR at runtime. **⚠ OSM plans differ from synthetic in shape (by design — real towns aren't the synthetic template):** - `plan.source === 'osm'` is set; `plan.size` is the real bounding box (~362×486), **not** 1024². - **No synthetic districts/types**: an OSM town has NO market square, arcade, dept anchor, milk bars, or stalls — only the real retail types (record/opshop/toy/book/video/pawn). Any code that ASSUMES a dept/market/milkbar exists must guard on `plan.source !== 'osm'` (or just not assume). - Everything B–F actually consume is identical: nodes/edges/blocks/lots/shops schema, `ry`/ `frontEdge` semantics, `chunkIndex`, hours + the exactly-one-openLate-video rule all hold. - Shop `name`s are the REAL OSM names (e.g. "Central Catholic Bookshop"); `sign` is derived. Parody-transform of names is a documented deferred nicety, not a blocker. ## Round 4 (2026-07-14) ### → Lane F (A1): storeys checker (F2) confirmed — 0 warnings, no false negatives `tools/qa/consistency_check.mjs` `permittedMax` scoping (your F2) is **correct**. Verified this round: - `node tools/qa/consistency_check.mjs` across 7 seeds → **0 storeys warnings**; `qa.sh --strict` GREEN (4/4). - **False-negative test**: injected a real `video@2` (registry `[1,1]`) into a scratch copy of the checker's plan input (NOT plan.js) across 5 seeds → the storeys gate still fired every time. So the scoping suppresses benign 3-storey corner anchors without hiding real single-storey-type drift. ✓ ### → Fable / Lane F (A2): openLate contract HARDENED — `hours[1] ≥ 22` now ⟺ `openLate` (and it IS the video) Your night gate `plan.shops.some(s => s.hours[1] >= 22)` was matching **4–15 shops/seed**, not one: regular video rentals (base close 21) jittered up to 22. That contradicted the shell's own contract (`web/index.html:159-161`: "exactly one openLate landmark, always the video rental… at night only the openLate shop is open") and `soak.py:68`. Rather than ask you to change the gate, I made reality match it: - **plan.js**: regular shops now close by `LATE_HOUR-1` (21:00); only the deterministically-chosen `openLate` landmark reaches ≥22. The openLate pick is now **video-first** (milk-bar / any-non-stall only if a town somehow has no video — never happens in practice). - **Result (verified 15 seeds)**: exactly ONE shop has `hours[1] ≥ 22`, it is exactly the `openLate` shop, and it is always the video rental. `{s: s.hours[1] >= 22}` and `{s: s.openLate}` are now the same singleton — **gate on either**, they agree. - selfcheck.js asserts all of this now (exactly-one, field⟺threshold, is-video). **⚠ GOLDEN.hash changed: `0x098eec2b` → `0x3fa36874`.** This is the *intended* consequence of the A2 plan.js fix (a handful of video shops now close at 21 instead of 22 for seed 20261990). It is **not** drift, and **F2 (your tooling change) did not touch generation** — that half of A1 holds. selfcheck.js carries the new golden and is GREEN. Nothing else in a plan changed (same lots/shops/types/names/skins). ## Round 3 (2026-07-14) ### → Lane F: the `qa.sh` "storeys outside registry range" warnings are BENIGN (finding #5 closed) Your consistency gate prints ~5 warnings per run: `"N shop(s) have storeys outside their registry type range … else a plan↔registry drift — ask Lane A"`. **Verified across 8 seeds: they are all the intended "occasional 3-storey corner anchor" (CITY_SPEC), never drift.** Evidence: - Single-storey types (`video`/`milkbar`/`stall`, registry `[1,1]`) are observed at 1 storey ONLY — e.g. 412 video shops, none at 2. So the specific "video at 2" example does **not** occur. - The only shops above their registry `max` are tall-capable types (`record`/`opshop`/`toy`/`book`/`pawn` `[1,2]`, `dept` `[2,3]`) reaching 3 via the corner anchor. Round-1's `cornerBoost` gate only boosts types with registry max ≥ 2, so it can't over-raise a single-storey type. **To silence the warning correctly**, scope your check to the *permitted* max, not the strict registry max: ``` permittedMax(type) = registryMax >= 2 ? Math.min(registryMax + 1, 3) : registryMax // flag only shops with storeys < registryMin || storeys > permittedMax → that IS real drift ``` This rule is now documented in `docs/CITY_SPEC.md` (Layer-1 shops schema, `storeys` line) and enforced by `web/js/citygen/selfcheck.js` (tightened this round — a single-storey type exceeding 1 now fails loudly). ### → Lane F: `hours` / `openLate` contract is ready for your §3.5 wiring (no changes) `hours = [open, close]`, 24h integers, `0 ≤ open < close ≤ 23`. **Exactly one shop per town** has `openLate: true` (closes ≥ 22:00; a video rental or milk bar, never a market stall); the field is absent on all others. Gate on the field — `plan.shops.find(s => s.openLate)` — not a magic hours threshold. Your ROUND3_INSTRUCTIONS §Lane F states it correctly, so consume as-is.