Files
settled-reach/docs/workshops/body-map-viewer/araminta-round2.md
T
jpmschweitzerandClaude Fable 5 29c22cb728 docs(meta): body-map-viewer workshop — rounds, measurements, outcomes, as-built briefing
The complete workshop record: four round-1 positions, five round-2 syntheses
(incl. Troblum's adversarial pass with addendum + final scorecard — all seven
findings resolved), both lead interviews, Qatux's round notes and the 8-section
workshop-outcomes.md (the lakes message-crossing documented as process
history), measurement ⑥ (set_pixel/c1) + the population-survey and chunk/S2
addenda in the measurement docs, the brief's appendix updated through ⑥, and
architecture-briefing-final.md — Jeroen's outline written back as-built
(six-level ladder, lakes, ~9MB resident global tier). README row: Complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:57:45 +02:00

56 KiB
Raw Blame History

title, description, workshop, round, author, status
title description workshop round author status
Body Map Viewer — Araminta Round 2 (Synthesis) Final per-gridunit payload schema with TTL-split planes, opener/Region rung correction, label de-dup rule at step seams, final wire schema, lakes gap fix (morphology-fold + outflow-course endorheic cue) body-map-viewer 2 Araminta complete

Araminta — Round 2 (Synthesis)

Four items, per the coordinator's slice. Grounded in lead-interview-1.md's rulings (sparse CliffSegment adopted, Phase-4 scope, current-state-via-TTL-split map time, viewport canvases + envelope ratified), my own round-1 position, and the other three round-1 documents (dudley-round1.md, stig-round1.md, tyre-round1.md) plus dudley-round2.md for the envelope mechanics. All four items are now fully closed — no open cross-checks remain. (b) region-as-step-0 and the cliff wire shape closed three-way (Araminta + Tyre + Jeroen's ruling); (d)'s envelope-framing question closed by Dudley's StepCanvasResponse design (§(a) of his round-2 document): one flat tagged message, my default assumption confirmed without qualification.


(a) Final payload schema — TTL-split planes, concretely

The full field set, ruled and unified

Round 1 (mine) proposed six existing dense fields + one new dense field (settlement_id) + two sparse lists (courses existing, cliffs new). Jeroen's lead-interview-1 ratified the sparse CliffSegment shape and Phase-4 scope outright ("Cliffs — sparse CliffSegment feature list, Phase-4 scope... Aligns the rarity finding, Araminta's encoding logic, and Tyre's scope ruling"). Nothing about the field set changes here — this section's job is the thing round 1 didn't yet have to answer: which of these fields is static geometry vs. sim-state, and what does that split cost on the wire.

The TTL-split — field-to-plane assignment

Jeroen's ruling (lead-interview-1, ruling 2): "static geometry cached indefinitely-fresh (determinism), frozen/flooded carried as separately-cached short-TTL planes re-requested as sim time advances." This is a wire-framing decision, not just a cache-policy one — if frozen/flooded ride inside the same dense array as permanently-fresh fields, the client cache can't apply two different freshness rules to one byte blob. So the planes have to be physically separate fields, not a policy applied after the fact to a merged payload.

Static-geometry plane (cached indefinitely-fresh, D-227 determinism guarantee — same seed/body/position → same bytes forever, no TTL, no staleness check, only storage-budget eviction per Jeroen's amendment):

Field Type Why static
morphology Vec<u8> Classification zone — a pure function of terrain derivation, D-239 §6 vocabulary, no sim-time input. §(e) note: the existing Lake/OpenOcean discriminants gain a hydrology-basin-membership data source (post-ratification fix, CLOSED) — no wire change, same field, better-sourced values.
elev_q Vec<u8> Height — geology, not weather
moisture_q Vec<u8> Climate baseline (region-computed-once-inherited per D-243 §3), not a live sim tick value
vegetation Vec<u8> Derived from climate baseline + morphology, same determinism class
settlement_id Vec<u32> Settlement placement is a generation-time fact, not sim state
courses Vec<RiverCourse> River geometry is invented deterministically (T-1170), never moves. §(e) note: an overflow lake basin's outlet edge extends into this list once the D8 sourcing fix (§(e)) lands — the same field, no new carrier.
cliffs Vec<CliffSegment> Hydrology settled equilibrium (T-1177) — a pure function of seed, computed once per body, not re-solved on a clock

Sim-state plane (short-TTL, re-requested as sim time advances — the concrete instantiation of D-226's clock-bound region water-height model, governance/decisions/architecture.md §"Water is a dynamic depth state... a region property computed once per phase"):

Field Type Why sim-state
glaciation Vec<u8> 5-grade freeze state — this is the "frozen" half of the outline's frozen/flooded pair. Glaciation as permanent ice geology (a glacier that doesn't melt) is static; glaciation as seasonal frost is sim-state. Per D-226's clock model, both are the same field sampled at different clock buckets — the wire doesn't need two fields, it needs this one field re-requested on the seasonal clock-bucket rollover.
flooded new, Vec<bool> or folded bit — see below The outline's explicit ask (flooded: true/false) that today's morphology water-zone classes don't cleanly separate from permanent sea/lake morphology. This is the region water-height-vs-elevation comparison D-226 already specifies as the mechanism — a derived boolean per gridunit, computed at request time from the current region water-height clock state, not stored geometry.

temp_dc is the genuinely ambiguous one, and I'm ruling it static, not sim-state, for a specific reason worth stating. Temperature has a diurnal/seasonal clock term per D-226's model too, so it looks like a sim-state candidate at first read. But the Atlas map (this workshop's whole scope) is not a live weather display — it's a navigational reference the player consults at any moment, and the baseline climate classification (what makes a zone "arctic" vs "temperate" for morphology/vegetation purposes) is the region-computed-once-inherited value D-243 §3 promises, not the live diurnal offset. Treating temp_dc as static keeps it consistent with moisture_q/vegetation, which are already climate baseline values, not live samples — and it avoids a wire/cache inconsistency where morphology (static, climate-baseline-derived) and temp_dc (if sim-state) disagree about which clock reading produced the zone you're looking at. If a future pass wants a live "current temperature" readout as a distinct overlay (a genuinely different feature — a weather-report reading, not the map's base classification), that's an additive sim-state field on top of this, not a reclassification of the existing one. Flagging this reasoning explicitly since it's the one plane assignment that isn't obvious from the field name alone — Jeroen's "map time axis" ruling names frozen/flooded specifically, and I'm reading that specificity as intentional (temperature wasn't named alongside them), not an oversight.

The staleness axis, stated per field — why each side, explicitly

The coordinator flagged this precisely: the plane split isn't "things that sound weather-related vs. things that don't," it's a real distinction each field needs its own stated reason for, because two fields with superficially similar names (glaciation, temp_dc) land on opposite sides and a reader shouldn't have to infer why.

  • glaciation → SIM-STATE, because "frozen" is seasonal, not geological. The field's own doc comment already frames it as a "5-grade" classification, and D-226's clock model treats freeze/thaw as exactly the kind of clock-bucket-rollover term (diurnal/seasonal phase-stepped, RegionPhase memoized per bucket) the whole "current sim state" framing in Jeroen's ruling is about. A gridunit that reads glaciation: 4 (heaviest grade) in winter and a lower grade in summer is not lying about the world's geology — it's answering "what does this look like right now," which is precisely what a short-TTL, re-requested-on-clock-rollover field is for. Permanent ice sheets are not a separate wire concept — a body-wide-permanently-glaciated region still reads a high grade at every clock sample, so the sim-state framing costs nothing for the "always frozen" case; it only pays off (shorter TTL, re-fetch on season change) for the seasonal case, which is exactly where the map needs to be honest about "right now" rather than "always."
  • flooded → SIM-STATE, for the identical reason on the water axis. D-226's own water-height model states this almost verbatim: "Depth is time-varying via a deliberately cheap, deterministic, clock-bound water-height... floodplain / tidal-flat / seasonal-river emerge rather than being placed." flooded is a derived comparison (region-water-height-at-current-clock-phase vs. local elevation), not stored geometry — it cannot be static by construction, because the same gridunit legitimately answers true at high water and false at low water within one static-geometry payload's entire cached lifetime.
  • temp_dc → STATIC, because the map's temperature reading is a climate-baseline classification, not a live weather sample. This is the one that needs the explicit contrast: temp_dc could have a diurnal/seasonal clock term exactly like glaciation/flooded do (the same D-226 clock model covers temperature too — "one clock model drives temperature, flooding, tides, snow..."), so the distinction here isn't "does this field have a clock term at all" (glaciation, flooded, AND temp_dc all do, in the underlying sim). The distinction is what the Atlas map is using the field for. temp_dc on this payload feeds the zone classification — it's read alongside morphology/vegetation to answer "what kind of place is this," a determinism-class question (D-227: the same seed always classifies this gridunit as arctic tundra), not a "what's the weather doing this afternoon" question. If temp_dc rode the sim-state plane, a player who opens the Atlas in winter vs. summer would see the classification itself flicker between TTL refreshes even though the underlying terrain and biome haven't changed — that's a worse experience than the actual live-diurnal-shift case (frozen/flooded), where the flicker is the honest answer to "what does this look like right now." glaciation/flooded sim-state because their live value IS the map-relevant fact; temp_dc static because its live value is not what the map is answering with it — same underlying clock-bound sim mechanism, different consumer question, hence opposite plane assignment. This is the reasoning that makes the split non-arbitrary rather than a guess from the field name.

Separate planes vs. bitfields — the byte-cost comparison Jeroen asked for

Two candidate shapes for the sim-state plane, priced against T-1179's measured rates (6.00 bytes/cell raw MessagePack, ~0.32× that with PNG per-field ≈ 1.9 bytes/cell effective):

Option 1 — separate L8 planes, one byte per field (glaciation: Vec<u8> already ships this way; flooded: Vec<u8> as 0/1 or reusing the existing byte width). Cost: two single-channel PNG-encoded planes. Stig's measurement ⑤ already names the win here directly — "L8 is 49× cheaper at every size... a free win if any wire field... can ship single-channel before the client colorizes it" — and glaciation already is single-channel. At 330K gridunits, PNG-per-field measured 638,382 bytes total across all six existing fields (T-1179); a rough per-field share (not uniform, since fields compress differently, but glaciation's own RLE run-count — 28.7% of dense at 330K, "moderately compressible" — sits mid-table) puts one sim-state plane at roughly tens of KB PNG-encoded, not hundreds. Two separate planes (glaciation + flooded) roughly doubles that share but each stays independently requestable — the client can re-fetch just the sim-state pair without touching the static-geometry payload at all, which is the entire point of splitting them.

Option 2 — packed bitfield, both sim-state facts folded into fewer bytes (e.g. glaciation's 3 real bits + a flooded bit packed into one byte, per T-1179's bit-packing measurement). Cost: T-1179's bit-packing number is unambiguous and directly on point — bit-packing alone buys "~1718% off raw... real but modest," and critically: "packing bits first actually hurts DEFLATE's job on the low-entropy fields... by destroying their byte-aligned run structure; DEFLATE prefers finding runs of identical raw bytes over finding runs of identical bit-groups spread across byte boundaries." glaciation and a hypothetical flooded boolean are exactly the low-entropy, spatially-coherent case (glaciation already showed moderate-to-good compressibility; a flooded boolean over a coherent water body is likely similar or better) that Option 2's own measured finding says bit-packing actively hurts.

My call: Option 1, separate L8/single-byte planes, PNG-per-field encoded — not bitfields. Three independent reasons converge:

  1. T-1179's own measured finding rules bit-packing out on compression grounds for exactly this kind of field (low-entropy, spatially coherent) — PNG-per-field already beats bit-packed-then-PNG'd at every size in the table (0.32×/0.30×/0.32× vs 0.53×/0.56×/0.54× across 330K/2.07M/8.3M).
  2. Stig's measurement ⑤ prices the upload-side win of single-channel planes directly — L8 is 49× cheaper than RGBA8, and that win is available because the planes are separate single-channel textures, not because they're bit-packed. Packing bits into one byte doesn't change the texture channel count on the client side; it just makes the CPU-side unpack step more expensive for a compression loss.
  3. The TTL-split's whole point is independent re-fetchability. A packed byte containing both a static bit (if one were ever folded in) and a sim-state bit would force the entire byte to inherit the shorter TTL — you can't selectively re-request half a byte. Keeping glaciation and flooded as separate planes (both sim-state, so this specific tension doesn't bite today) is what keeps the TTL boundary aligned with the field boundary, which is the property this whole sub-task is asking for. Folding sim-state and static fields into the same byte would be the one packing move that actually breaks something structural, not just costs a few percent — good thing neither candidate here needs that (both glaciation and flooded are sim-state, so packing them together, if ever done, wouldn't cross the TTL boundary — but I'm not recommending it anyway, given point 1).

Concrete byte cost, stated plainly: at the PNG-per-field encoding already adopted for the rest of the schema, the sim-state plane (two L8 fields, glaciation + flooded) costs on the order of the same per-field share any other moderately-compressible classification field costs in T-1179's table — a small fraction of the total step-canvas payload, not a meaningfully separate budget line. The TTL split is a freshness/re-request-cadence decision, not a bytes-saved decision — splitting the planes doesn't shrink the payload, it lets the client avoid re-fetching the (much larger) static-geometry majority of the payload when only the sim-state pair has gone stale. That's the actual value: bandwidth saved on re-fetch frequency, not on first-fetch size.

Schema, final form

StepCanvas {
    // envelope/echo fields (existing pattern, carried forward)
    center, step_index, gridunit_spacing_m, min_wl_m, ...

    // STATIC GEOMETRY PLANE — cached indefinitely-fresh, D-227 determinism
    morphology:     Vec<u8>,           // dense, PNG-per-field — Lake/OpenOcean discriminants now
                                        // basin-sourced from HydrologyResult (§(e), CLOSED), no wire change
    elev_q:         Vec<u8>,           // dense, PNG-per-field
    temp_dc:        Vec<i16>,          // dense, PNG-per-field — climate baseline, not live reading
    moisture_q:     Vec<u8>,           // dense, PNG-per-field
    vegetation:     Vec<u8>,           // dense, PNG-per-field
    settlement_id:  Vec<u32>,          // dense, PNG-per-field (new)
    courses:        Vec<RiverCourse>,  // sparse, MessagePack (existing, T-1170) — overflow-basin outlet
                                        // edges extend into this list once the D8 sourcing fix (§(e)) lands
    cliffs:         Vec<CliffSegment>, // sparse, MessagePack (new, ratified lead-interview-1)

    // SIM-STATE PLANE — short-TTL, re-requested on clock-bucket rollover
    glaciation:     Vec<u8>,           // dense, PNG-per-field, L8
    flooded:        Vec<u8>,           // dense, PNG-per-field, L8 (new)
}

Note on the lakes fix (§(e)): no new field appears in this struct. The originally-proposed water: Vec<u8> and lakes: Vec<LakeBasin> candidates are both withdrawn — Dudley's post-ratification finding (MorphologyZone::Lake already exists, just poorly sourced) means the fix is a derive-pipeline data-source change, not a wire-schema addition. morphology and courses carry annotated notes above marking where the fix lands; no field count changes.

CliffSegment shape (unchanged from round 1, restated for completeness): { cell: (u16, u16), channel_depth: u16, /* cliff_edge implied by list membership */ } — direct, non-lossy carry of HydrologyResult's channel_depth_scaled/cliff_edge fields (Dudley, T-1177), sparse list parallel to courses, #[serde(default)] empty on non-carving windows.

Client rule, restated once more since the TTL split makes it sharper: the client may cache the static-geometry plane forever and treat the sim-state plane as a separately-expiring sub-fetch — but it may never infer flooded/frozen state from the static plane (e.g. inferring "flooded" from morphology's water-zone classes as a substitute for the real flooded field). The two planes answer different questions (permanent water body vs. current water-height-vs-elevation state) and conflating them client-side would silently violate the very TTL split this section defines.


(b) Region-as-step-0 — CORRECTED post-ratification (Jeroen's review; Tyre doing the parallel correction in his own amendment texts)

Status update, stated plainly before the corrected text: my three-way "CONFIRMED" closure below was wrong on rung count, right on spacing intuition. Jeroen's post-ratification review caught the gap: I collapsed two distinct rungs into one. The corrected model, per the coordinator's relay:

GLOBAL is rung 0 — the body-surface opener, a VARIABLE-extent canvas (the body's entire region grid, one gridunit per region — this is the D-243 elastic seam made visible on the wire, not hidden above the ladder as I originally wrote). This is the canonical, always-kept tier. REGION is rung 1 — the largest FIXED-size rung: viewport-sized, evictable, exactly like every other step below it.

My original text conflated these because I reasoned from spacing alone ("step 0 needs a D-243 rung; Region is the coarsest one; therefore step 0 IS Region") and missed that the opener's defining property isn't its gridunit spacing at all — it's that its canvas extent is per-body floating (round(2πR / 204.8 km) regions around the equator, D-243's own elastic-seam formula), which is categorically different from every other step's fixed-px-budget canvas (my own (a)/(d) sections, Dudley's 3840×2160 convention). A rung whose extent varies per body cannot be "the Region rung" in the same sense Region-at-rung-1 is, even though both sample at region-sized gridunits. Spacing and extent are two different axes, and I only checked one.

The corrected reasoning, restated on the right axis

  1. Gridunit spacing at the opener is still region-sized — that part of my original argument holds unchanged. Nothing about the correction invalidates why region-spacing is the right sampling density for a whole-body view (T-1179's cell-count-scales-with-area argument, D-226(d)'s whole-body-planetary-layer boundary at anything finer). The opener's gridunits ARE region-sized. What I got wrong is naming that fact "step 0 = the Region rung" instead of "step 0 samples at Region spacing, but is its own rung above Region because its extent is the variable whole-body grid, not a fixed viewport."
  2. The canonical/always-keep property moves with the correction, and this is the load-bearing consequence for my (a) TTL-split work. Dudley's round-1 global-tier costing (~174 MB PNG-encoded across all 273 bodies) and my own (a) section's "static plane cached indefinitely-fresh... only the global tier keep-always" language were written assuming Region was step 0 — they need to be read as describing rung 0 (the opener), not rung 1. Region (rung 1) is now correctly a storage-evictable sub-global tier, same as every rung below it — it does NOT inherit "canonical" just because it's the largest fixed rung. This is consistent with, not a reversal of, Jeroen's lead-interview-1 ruling ("canonical survives only as the global tier") — the correction is which rung is "the global tier," not whether the keep-always/evictable split itself holds.
  3. The elastic seam is not "above the ladder, never derived at" as I originally wrote — it's rung 0's own canvas-extent parameter. My original bullet 3 said "the elastic seam sits above [Region], at the per-body planetary count, which is not itself a spacing value the Atlas derives at." That's the specific sentence Jeroen's correction overturns: the elastic seam IS what the Atlas derives at rung 0 — round(2πR / 204.8 km) regions isn't a hidden background fact, it's the literal gridunit count of the opener's canvas, visible to the player as "how big does this body's region-grid look." This is a genuine strengthening of D-166's "seamless zoom ladder" mandate, not a complication: the elastic seam was always going to be visible somewhere the first time a player opens two differently-sized bodies' Atlas views side by side, and rung 0 is the correct, honest place for that to surface, rather than being smoothed over.

What this changes in my payload schema (a) and wire schema (d) — corrected, not reworked

No field-set change. Rung 0's StepCanvas still uses the identical field set from (a) — same six-static-plus-two-sim-state dense fields, same two sparse lists, same whole-body name lookups. The correction is entirely about which rung is canonical and what "extent" means at the top, not about the payload contents. Concretely:

  • EncodedStepCanvas.width/.height (Dudley's struct, §(d)) are fixed constants at every rung except rung 0, where they are instead derive_region_count(body_radius_km) — the same per-body function D-243's elastic seam already defines, not a new derivation. This is the one place in the whole ladder where width/height are a body-dependent value rather than the shared fixed-px-budget convention — worth a doc-comment on the struct so an implementer doesn't assume every response has the same canvas dimensions.
  • The cache-tier "keep-always" retention floor (Stig's round-1 §4, Dudley's round-2 §(d) global tier) now pins specifically on rung 0's entries, keyed by (body_id, rung=0) — not on (body_id, rung=1 /*Region*/) as my prior text implied. Region (rung 1) entries flow through the same storage-TTL/time-since-last-visit eviction every other sub-global rung uses.
  • Step index numbering shifts by one relative to my prior text throughout this document: everywhere I previously wrote "step 0" I should now write "rung 0 (the opener)," and every reference to "Region as the top of the sub-global ladder" now correctly reads as "Region, rung 1, the first fixed-size step below the opener." I'm not renumbering every instance below for this correction pass — flagging the mapping once, here, as the authoritative key: rung 0 = opener (variable, region-spaced gridunits, canonical) → rung 1 = Region (fixed, viewport-sized, evictable) → rung 2 = District → ... → deepest rung = whatever Jeroen's chunk(64m)-bottom ruling lands on.

Convergence status

Tyre is producing the equivalent correction directly in his amendment texts (D-243 gridunit entry, the step-ladder table) per the coordinator's note — I'm not routing this one for a fresh cross-check the way I did the original (b) confirmation, since Jeroen's own review is the authority here and both of us are independently correcting against the same relayed ruling, not proposing competing readings. If Tyre's amendment text names the rungs differently (e.g. a different rung-0 label than "opener/GLOBAL"), I'll conform my terminology to his filed text rather than contest it — this section's job is getting the architecture right for my schema's sake, not owning the naming.


(c) Label de-dup at step seams — the concrete rule

Tyre's gray case (round-1 §3, bucket C): "is client-side de-duplication... legal presentation logic, or does it risk client and server disagreeing about which gridunit owns this feature's label?" His own read: "legal, IF the server always ships feature identity (a stable id) rather than the client inferring identity from proximity." My round-1 payload schema already ships exactly that precondition (settlement_id per gridunit, edge_id on every RiverCourse) — so the precondition is met, and the rule below is the concrete mechanism that makes the "legal" branch actual, not just theoretical.

The rule

Deterministic anchor choice, keyed on feature identity, never on which canvas/step happened to fetch first.

For settlements (settlement_id occupying a contiguous run of gridunits within one step's canvas, and potentially spanning a step or canvas-tile seam at low zoom):

The label anchor is the arithmetic-mean gridunit position of every cell carrying that settlement_id within the current step's canvas, rounded to the nearest gridunit via a fixed tie-break rule (round half toward the lower (x,y) in row-major order — an arbitrary but stable tie-break, the same discipline label_lake_basins' row-major discovery order already uses for determinism, T-1177). This is a pure function of (step, canvas_bounds, settlement_id, the cells carrying it) — same inputs, same anchor, always, on both the server-echo side and any two clients computing it independently.

Why this handles the seam case without special-casing it: a settlement whose footprint is split across two adjacent canvas tiles at the same step (the mosaic case, not the step-cross case) has each tile compute its own mean over only the cells that tile actually received — which means the two tiles will independently compute two different anchor points (each tile's own partial-footprint centroid), and both draw a label. This sounds like the bug the gray case worried about, but it isn't, once you name the actual failure mode precisely: the risk was never "two labels for one settlement," it's "two labels disagreeing about which settlement, or a label a player can't correlate with the feature." A settlement large enough to span a canvas-tile seam at a given step is, by construction, large enough that both halves are visibly the same feature to the player (its footprint fill color and settlement_id match on both tiles) — showing the name near each visible fragment is correct map behavior, not a duplication bug, the same way a real paper-map atlas repeats a country's name across each visible page-tile its territory spans. The actual dedup requirement is narrower than "one label globally": it's "never show two different names for the same id, and never let two different ids merge into one label." Both are structurally impossible under this rule, since the anchor computation and the name lookup are both pure functions of settlement_id — there is no code path where the id resolves to different text on two tiles.

Where genuine single-label dedup DOES matter: the step-cross case, not the tile-seam case. When the player crosses a step boundary (not a tile boundary within one step), the previous step's held canvas and the newly arrived step's canvas briefly coexist during hold-fetch-swap (Stig's round-1 §2). Both canvases might carry labels for the same settlement during that transition window. Rule: the annotation layer only ever draws labels from the currently-authoritative canvas — the held (stale, about-to-be-replaced) canvas's annotation layer is not drawn during the hold interval, only its terrain texture is (magnified, per the between-step-magnification red flag). This isn't a new mechanism — it falls directly out of Stig's own architecture: the annotation layer is described as an unscaled sibling that "re-run[s] the annotation layer's world→screen transform against the new step's bounds" on arrival, which already implies the annotation layer belongs to one step's data at a time, not both simultaneously. I'm stating the corollary explicitly because "which canvas's annotation layer is currently drawn" wasn't spelled out as a rule anywhere in round 1's four documents, and it's exactly the kind of implicit assumption that produces the doubled-label bug if an implementer doesn't carry it forward.

For river name labels (a RiverCourse polyline, potentially cropped differently by two adjacent windows per Ruling 1e's window-independence invariant): the same identity-first principle, adapted to a line feature rather than a point/area feature:

The label anchor for a named river is the midpoint, by arc-length, of the cropped points array the current canvas received for that edge_id — not a global midpoint of the river's full extent (which the current canvas may not have visibility into at all, per Ruling 1e: the geometry is a pure function of (seed, body, edge, rung), cropped per-window, never re-parametrized). Each canvas places its own river label at its own cropped-midpoint, independently. Same non-issue as settlements: a long trunk river visibly spans multiple canvas tiles, and a label per visible tile is correct cartography, not a duplication bug, as long as every tile's label resolves to the same name via the river_names lookup (§(d) below) keyed on the same edge_id — which it structurally does, by construction, same argument as settlements.

One explicit non-goal, stated so a future implementer doesn't over-engineer this: I am not proposing any cross-tile or cross-canvas coordination protocol (no "who owns this label" arbitration, no server-side label-placement pass). The rule above needs zero new wire data and zero new server logic — it's entirely a client-side presentation policy operating on data the schema in (a) already ships (settlement_id, edge_id, the cropped geometry). This is deliberately inside Tyre's bucket-A "legal interpolation" territory: the client is placing a label for an already-identified thing, never deciding that a thing exists or what it's called.


(d) Final wire schema — CLOSED (envelope framing confirmed by Dudley)

Envelope-framing question resolved: shape (a), one flat tagged message. Routed to Dudley via the coordinator — asked whether the tagged envelope frames the full StepCanvas as one flat message (my default assumption, matching today's DistrictWindowLayer shape scaled up) or splits dense/sparse into separately-tagged sub-messages for progressive delivery. Dudley confirmed (a) without qualification (dudley-round2.md §(a)): every field comes off the same row-chunked derive pass in one traversal, so a dense/sparse split saves no server compute and only adds round-trips and partial-state handling — the identical argument D-225 already used against per-layer whole-body requests. The progressive-paint UX case (terrain visible before courses/cliffs finish decoding) is real but solved client-side for free: courses/cliffs are a tiny fraction of total decode time (a few KB against a multi-hundred-KB-to-tens-of-MB dense payload), so the client paints the terrain RTT layer immediately and defers the annotation layer's draw calls by a frame — a rendering-order choice, not a wire-protocol one. He'd revisit only if a future profiling pass found the dense and sparse derive passes on genuinely different cost timelines; nothing measured this workshop shows that (courses cost +0.090.21 ms against a ~5 ms baseline — negligible, not staggered).

Concrete carrier, per Dudley's StepCanvasResponse design (matching my field list exactly, field-for-field, including the resolved cliffs: Vec<CliffSegment> sparse shape):

pub struct StepCanvasResponse {
    pub body_id: String,
    pub step_index: u32,
    pub center: (i64, i64),        // echoed, same staleness-guard pattern as district_window
    pub status: StepCanvasStatus,  // Ready | Pending | Error(String)
    pub canvas: Option<EncodedStepCanvas>,
}

pub struct EncodedStepCanvas {
    pub width: u32,
    pub height: u32,
    pub morphology:    EncodedField,  // PNG-per-field — Lake/OpenOcean now basin-sourced (§(e), CLOSED)
    pub elev_q:        EncodedField,  // PNG-per-field
    pub temp_dc:        EncodedField,  // PNG-per-field
    pub moisture_q:     EncodedField,  // PNG-per-field
    pub vegetation:     EncodedField,  // PNG-per-field
    pub settlement_id:  EncodedField,  // PNG-per-field (my new dense field)
    pub glaciation:     EncodedField,  // PNG-per-field, L8 — sim-state plane
    pub flooded:        EncodedField,  // PNG-per-field, L8 — sim-state plane (my new field, needs adding to his struct)
    pub courses: Vec<RiverCourse>,     // sparse, MessagePack-native — overflow-basin outlets extend this (§(e))
    pub cliffs:  Vec<CliffSegment>,    // sparse, MessagePack-native
}

One reconciliation note against Dudley's struct as literally drafted: his dudley-round2.md §(a) code sample predates my TTL-split work in this document and lists glaciation alongside the other five dense fields without a flooded field or a plane distinction — that's sequencing, not disagreement (his draft cites "Araminta's new dense field settlement_id" by name, so he was already building directly off my round-1 schema, just before the sim-state-plane work landed in this round-2 pass). EncodedStepCanvas needs flooded added as a ninth field. No further field is needed for lakes — §(e)'s final answer (post-ratification, superseding an earlier draft of this section that proposed water/lakes as new fields) reuses morphology and courses unchanged; the lakes fix is a derive-pipeline data-source change (Dudley's basin-membership sampling into derive_morphology_zone, plus a follow-up D8-wiring task for the outflow-course extension), never a wire-schema addition. Dudley's whole-payload-together envelope argument still applies to flooded — one more field derived in the same row-chunked pass doesn't reopen the flat-vs-split question — but there is nothing further to reconcile on the lakes item specifically; it resolved to zero new fields.

Encoding, confirmed unchanged from round 1

PNG-per-field for every dense array field, MessagePack-native for every sparse list field. Nothing in round 1's cross-agent convergence or Jeroen's lead-interview-1 rulings changes this — it's the unambiguous T-1179 winner (smallest and fastest at every measured size, 21×–563× better than the alternatives against the reference cap), and no other participant's round-1 position argued for a different encoding. Restating the two encoding rules precisely, now that the field list is final:

  • Dense fields (morphology, elev_q, temp_dc, moisture_q, vegetation, settlement_id, glaciation, flooded): each field is its own PNG-encoded plane. glaciation and flooded specifically as L8 (single-channel, per Stig's measurement ⑤ finding that L8 is 49× cheaper than RGBA8 at every size) — both are already single-byte values with no need for a wider channel format.
  • Sparse fields (courses, cliffs): MessagePack-native Vec<Struct>, unencoded beyond the envelope's own framing — both are already small relative to the dense planes (T-1170's ~12 KB typical for courses; cliffs smaller still given the rarity finding), and MessagePack's compact bin/array framing is already efficient at these sizes per T-1179's own measured 6.00 bytes/cell raw rate finding (no wasted per-element type tags).

The full field list, final

Restating (a)'s schema in wire-contract form, the complete answer to "the per-gridunit payload schema + named-feature encoding + envelope framing" (brief Expected Output 4):

Per-step-canvas payload (rides the tagged envelope, §1d's amendment text — the new carrier, not district_window):

  • 6 static + 2 sim-state dense fields (listed in (a) — field count unchanged from the original round-2 pass; §(e)'s lakes fix reuses morphology, adding no field)
  • 2 sparse feature lists (courses, cliffs) — courses gains overflow-basin outlet edges once §(e)'s D8-wiring follow-up lands, same field, no new carrier
  • Envelope/echo fields: center, step_index (replacing granularity/ granularity_v2 per Tyre's §1c select_rung-replacement ruling), min_wl_m (unchanged purpose — octave cutoff echo, still needed for cache-key correctness per the existing doc comment's reasoning). At rung 0 (the opener), width/height are body-dependent (derive_region_count(body_radius_km)) rather than the fixed 3840×2160-class budget every other rung uses — per (b)'s correction.

Whole-body lookups (unchanged carrier — the existing names-only request path, D-223's shape, fetched once per body, never inside the step-canvas envelope):

  • atlas_city_names (existing)
  • river_names: BTreeMap<u32 /* edge_id or trunk-root id */, String> (new, my round-1 proposal, unchanged in round 2 — nothing in round 1's cross-agent discussion touched river naming, so I'm carrying it forward as-is)

What round 1 already confirmed and I'm not re-arguing

  • The tagged-envelope migration itself: triggered, not merely likely (Tyre §1d, unanimous across Dudley/Araminta/Tyre in round 1).
  • Viewport-sized canvases at every rung below the opener (Dudley + Tyre, independently argued convergence) — corrected per (b)'s post- ratification fix: rung 0 (the opener, variable per-body extent) is the one exception; Region is now rung 1, a fixed, viewport- sized, evictable rung like every rung below it, not the exception rung. Every rung from Region (rung 1) down uses the viewport-sized policy — the "whole-body canvas" property belongs to rung 0 alone.
  • One colorizer family, same field set at every step (my round-1 §3, reconfirmed here as applying to the final field list including the new cliff/sim-state fields — nothing about the TTL split or the cliff ratification changes the "same vocabulary at every step" guarantee, since neither is a new classification, just a new field).

Byte-size implication of the final schema (not a new measurement — reasoning from T-1179's numbers)

The final schema adds two new dense fields (settlement_id, floodedglaciation already existed) and one new sparse field (cliffs) to T-1179's originally-measured six-field set. None of these change the order-of-magnitude verdict: T-1179's own six-field payload was already 21×–563× the 30 KB reference cap at the three measured sizes; two more near-free sparse/single-channel-dense fields (settlements are rare and spatially coherent per the RLE precedent; cliffs are measured as near-zero-occupancy) don't meaningfully change that ratio. The tagged-envelope call remains settled by round 1's numbers — I'm not requesting a re-run of T-1179 for the final field list, since the schema's byte-cost character (dominated by the same morphology/vegetation-vs-elev_q/temp_dc compressibility split T-1179 already characterized) hasn't changed, only grown by a small, well-understood margin.


Summary for the lead interview

  1. (a) Payload schema, final: 8 dense fields (6 static-geometry + 2 sim-state: glaciation, new flooded) + 2 sparse lists (courses, new cliffs, the latter CLOSED per (b) below) + 2 whole-body name lookups (atlas_city_names, new river_names). TTL split is physical field separation, not a post-hoc policy — static plane cached indefinitely-fresh (with storage-budget eviction per Jeroen's amendment, distinct from staleness), sim-state plane short-TTL and independently re-fetchable. Byte cost: separate L8 planes win over bitfields on both compression (T-1179) and correctness (independent re-fetchability) grounds — ruled against packing. Staleness-axis reasoning stated per field, not inferred from the name: glaciation/ flooded are sim-state because their live value is the map-relevant fact (seasonal frost, current water-height-vs-elevation); temp_dc stays static because the map consumes it as a climate-baseline classification input, not a live weather reading — same underlying D-226 clock mechanism, different consumer question.
  2. (b) Rung 0 (opener) vs. rung 1 (Region): CORRECTED post- ratification, not the three-way "Region = step 0" closure this summary previously stated. Jeroen's own review caught the gap: the opener is its own rung (rung 0, variable per-body extent — round(2πR / 204.8 km) regions, D-243's elastic seam made visible on the wire, canonical/always-kept) sampled at region-sized gridunits; Region is rung 1, the largest fixed, viewport-sized, evictable rung — not the canonical tier. My spacing intuition (region-sized gridunits at the top) was right; my rung count was wrong (I collapsed two rungs into one). Tyre is producing the equivalent correction in his own amendment texts in parallel; I'm conforming to his filed terminology rather than contesting it if it differs from "opener/GLOBAL." Also closed, unaffected by this correction: the cliff wire shape (cliffs: Vec<CliffSegment>, sparse list, zero-length when nothing carved, #[serde(default)], parallel to courses) — Tyre confirmed his round-1 "new arrays over steal-a-bit" language was against bit-stealing an existing byte, not for dense per-gridunit arrays.
  3. (c) Label de-dup: a deterministic, identity-keyed anchor rule (mean-position/tie-break for point features, cropped arc-length midpoint for line features), computed independently per canvas/tile — explicitly not a cross-tile coordination protocol. The real seam risk was never "two labels," it was "two labels disagreeing" — both structurally prevented by the schema already carrying stable ids. Added the missing step-cross corollary (only the authoritative canvas's annotation layer draws during hold-fetch-swap) since no round-1 document stated it explicitly.
  4. (d) Final wire schema: CLOSED. PNG-per-field (dense) + MessagePack-native (sparse), unchanged encoding verdict from round 1, field list finalized per (a) with the cliff shape closed per (b). Envelope framing confirmed by Dudley (dudley-round2.md §(a)): one flat tagged StepCanvasResponse/EncodedStepCanvas, my default shape (a) held without qualification — every field derives in the same row-chunked pass, so splitting the wire buys no compute savings and only adds round-trip/partial-state cost; the progressive-paint UX case is solved client-side (deferred annotation-layer draw calls) for free. One implementation note carried forward: EncodedStepCanvas as Dudley drafted it needs flooded added as a ninth field (sequencing, not disagreement — his draft predates this round's TTL-split work) — the addition doesn't reopen the flat-envelope ruling, since the plane split is a client-side re-fetch-cadence concept, not a wire-framing one.

Status at this point in the document: (a), (c), (d) fully closed, no open cross-checks. (b) carries a post-ratification correction (rung 0/opener vs. rung 1/Region, per Jeroen's own review) rather than standing as originally filed — the correction is architectural (which rung is canonical, what "extent" means at the top), not a reopening of the field-set or wire-schema work in (a)/(d), both of which are unaffected. One further item follows this summary, §(e) below (lakes) — read past this point before treating the document as final; the true closing status is stated at the end of §(e).


(e) CLOSED — lakes: morphology-fold, sourced from settled hydrology; endorheic cue via outflow-course presence

Final answer, superseding my original "new water field" proposal below. Dudley's post-ratification addendum (dudley-interview2-response.md §5) corrects a factual premise my original rejection of folding into morphology was built on — I verified his code-read directly (district_profile.rs:556-565, generator.rs:1198-1223, features.rs:104-105) before accepting it, not on his say-so. The reconciliation below is mine to make since I own encoding; his derive-pipeline slotting is adopted unchanged.

What I got wrong originally, and what actually resolves it

My original §(e) rejected folding into morphology on the premise that lake-vs-not would "require... widening an already-frozen vocabulary." That premise is false. MorphologyZone::Lake (discriminant 1) already exists in the frozen 17-zone enum (generator.rs:1202, doc-commented "Lake body — hub-and-spoke; perimeter access toward water") — confirmed by direct read, not assumed. The gap was never a missing vocabulary entry; it's a missing data source. Today, derive_morphology_zone emits Lake from a crude threshold (ocean_fraction_q >= 60, district_profile.rs:563-565) where ocean_fraction_q is a bilinear sample of TerrainAnalysis .ocean_mask, itself nothing more than elev[i] < sea_level on the raw heightmap (features.rs:104) — zero connection to HydrologyResult's settled-equilibrium basins. The code's own comment at line 560 admits this ("Lake differentiation lives at ChunkContext") — it was already a known, named gap before this workshop, just never connected to the solver this workshop's ① measurement is about.

My second objection — that per-cell basin membership is finer-grained than morphology's region-level classification — is answered by Dudley's sourcing fix, not by a new field. The fix doesn't ask morphology to carry a coarser aggregate than the solver computes; it asks derive_morphology_zone to gain a third input (basin membership, sampled the identical bilinear way ocean_fraction_q already is) and emit the existing Lake discriminant when that membership test hits, falling through to today's heuristic otherwise. This is per-gridunit resolution at the SAME granularity every other morphology gate already operates at (derive_morphology_zone is called per-gridunit today, gated on slope_q/elev_q/ocean_fraction_q — all already per-cell bilinear samples of coarser working-grid fields). There was never an actual grain mismatch; I mis-stated the objection by treating "basin membership" as if it were categorically different from every other per-cell classification input derive_morphology_zone already consumes, when it's the identical shape.

Verdict: fold into morphology. No new dense field. Zero new wire bytes. water: Vec<u8> from my original proposal is withdrawn — the byte-cost argument I made for it (near-zero, PNG compresses contiguous classification blobs well) transfers unchanged to reusing morphology, except the cost is now not just near-zero but exactly zero, since no new array exists at all. This is a strictly better outcome than my original proposal on every axis I originally argued from (byte cost, frozen-vocabulary discipline, one-colorizer-family consistency) — the correction makes the schema simpler, not just different.

Sea vs. Lake stays exactly as todayOpenOcean/Lake are both already-existing discriminants; nothing about this fix touches the ocean_fraction_q >= 80 open-ocean tier. Only the Lake emission site gains a second, more-authoritative trigger (basin membership) ahead of the existing heuristic fallback.

Endorheic-vs-overflow — corrected mechanism, honest wiring caveat stated

The coordinator's flagged weak point is real, and I'm ruling against my own original proposal's premise, not defending it. My original "distinguishes via the sparse cliffs list" reasoning assumed carved cliff_edge cells would exist often enough to be a usable signal. T-1177's own POPULATION SURVEY closes that door completely: zero carved cells, zero carved-outlet basins, across all 267 real committed bodies (100.00%) — not "rare," observed nowhere. A visual cue wired through cliffs would never fire in practice. I checked this number directly in measurements/t1177-hydrology.md before accepting the correction, not on the coordinator's relay alone.

Chosen mechanism: option (a), outflow-course presence — zero new data, semantically true, matching option (b)/(c)'s crossover concern without needing either. An overflow basin, by BasinOutcome definition, has a resolved outlet path to the sea/another basin/open ground; an endorheic basin has none. A river course (courses: Vec<RiverCourse>, T-1170) leaving a lake gridunit IS the honest visual signal for "this lake drains" — no course leaving is the honest signal for "this lake doesn't." This needs no new field, no per-basin outcome byte, and sidesteps the crossover-size question I raised for option (b)'s Vec<LakeBasin> entirely (that question is now moot — there is no lakes list to size).

Honest wiring caveat, stated precisely because it's the one place this isn't yet "free" in the way the coordinator's framing implied: T-1177's own document is explicit in its "does NOT do" scope — "HydrologyResult/Basin/BasinOutcome are prototype-only types, not wired into RiverNetwork... or any AtlasLayerResponse payload." I checked this directly (t1177-hydrology.md line ~362) rather than assuming the outflow-course mechanism already connects end-to-end. It does not, today. courses/river_downstream are D8-network-derived (T-1170's mechanism, keyed on fdir[i]/river cells extracted by drainage.rs's existing flow analysis); a hydrology basin's resolved outlet_path (Dijkstra overflow routing, a different computation) is not currently threaded into that D8 river network at all. This is not a reason to reject option (a) — Ruling 7b of the T-1170 design document already anticipated exactly this extension and pre-cleared it as additive ("the TERMINAL downstream sentinel is reserved now...an interior sink becomes an additive drainage-extraction change, not a wire migration; and the termination mechanism... generalizes unchanged to a terminal lake shoreline. When you want endorheic basins, the work is in D8 sink retention and lake morphology, and every piece this batch builds consumes them without modification") — but it IS a real, small, implementation-scoped wiring task, not something the wire schema gets "for free" the moment water=Lake ships. Stating the honest scope: an overflow basin's spill point needs to register as a D8 downstream continuation (feeding river_downstream) rather than terminating at the lake, so courses picks up the outlet edge naturally; an endorheic basin's absence of such an edge is what already reads as "no course" under the existing mechanism, requiring no change at all. This is follow-up implementation work for whoever wires basin membership into derive_morphology_zone (Dudley's pipeline-slot answer, below) — flagging it explicitly rather than letting the schema doc imply the visual distinction ships automatically the day water=Lake lands.

Visual treatment — unchanged from my original proposal, still holds

Lakes draw as water at every rung, same colorizer family as sea/ocean morphology — no new palette, per my round-1 §3 one-colorizer-family guarantee (this was never contingent on the field being water vs. morphology — a Lake discriminant colors identically either way). Endorheic-vs-overflow reads via course presence at the lake's outlet, not a distinct fill color or texture on the lake body itself — simpler than my original "secondary visual cue" proposal (texture/saturation/ outline treatment), since a present-or-absent outflow course is the visual difference, no additional styling rule needed from Stig beyond drawing courses the way (c)'s label-anchor work already assumes he does.

Byte cost — better than originally argued, not merely confirmed

Exactly zero new wire bytes, stronger than my original "near-zero" claim. morphology's existing per-field cost (already priced across every canvas size in T-1179's table) is unchanged in shape — only the source of a subset of already-shipping Lake/OpenOcean discriminant writes moves from a heightmap threshold to a hydrology-basin test. No new array, no new encoding question, no new crossover-size analysis (the Vec<LakeBasin> sizing question is withdrawn along with the field it would have carried).

Pipeline slot — Dudley's answer, adopted unchanged

HydrologyResult computed once per body at 512×256 (held in the D-203-shaped global-tier cache, T-1177's own scope); a finer-rung canvas derive needs basin membership at a gridunit's world position — Dudley's proposal: TerrainAnalysis gains a basin-membership field (basin id or bool, populated once when hydrology solves), sampled the SAME bilinear way ocean_mask/ocean_fraction_q already are, feeding derive_morphology_zone's existing water tier as a new gate ahead of the ocean_fraction_q fallback. This is round 2 §(b)'s seed-chaining mechanism B (sample a coarser rung's continuous primitive, never re-solve) applied to hydrology exactly as it's already applied to sea_level — no new architecture, no new bench needed (his own per-cell rate numbers already include equivalent-cost sampling operations in the measured budget). Adopted without modification — this is implementation-pipeline territory, not schema/encoding, and his reasoning is sound on direct inspection.

Convergence status: CLOSED, no open sub-questions remain

Unlike the original §(e), there is no remaining sizing call routed to Dudley — the lakes: Vec<LakeBasin> vs. water 4th-value crossover question is moot (neither ships; the fold-into-morphology answer needs neither). The one caveat carried forward is the outflow-course wiring gap named above, which is implementation scope for whoever lands the basin-membership sourcing fix, not an open design question for this document.


True closing status (supersedes the "Summary for the lead interview" section above) — DOCUMENT FULLY CLOSED

  • (a) Payload schema: closed. Field count is unchanged from the original round-2 pass (six static + two sim-state dense fields, two sparse lists) — §(e)'s lakes fix reuses morphology/courses, adding nothing.
  • (b) Rung 0/1 split: corrected in this pass (opener = rung 0, variable extent, canonical; Region = rung 1, fixed, evictable) — architectural correction, terminology pending final conformance to Tyre's parallel amendment text.
  • (c) Label de-dup: unaffected by both post-ratification items, closed as before.
  • (d) Wire schema: closed. No field-count change from §(e) — the EncodedStepCanvas struct is annotated where the lakes fix lands (morphology, courses) but gains no new field.
  • (e) Lakes: CLOSED, no open sub-questions. Reversed my own original proposal after verifying Dudley's code-read directly (district_profile.rs, generator.rs, features.rs): morphology already carries a Lake discriminant, mis-sourced from a heightmap threshold instead of settled hydrology — the fix is a data-source correction (Dudley's basin-membership sampling, adopted unchanged), not a wire addition. Endorheic-vs-overflow resolved via outflow-course presence (option (a) per the coordinator's framing), after directly confirming in t1177-hydrology.md that the originally-proposed cliffs-based cue would never fire (zero carved cells, 267/267 real bodies) — ruled against my own earlier reasoning once the population survey data made it untenable. One honest implementation caveat carried forward (not a design question): HydrologyResult's basin outlets aren't yet wired into the D8 river network courses draws from — pre-cleared as additive by T-1170 Ruling 7b, follow-up work for whoever lands the sourcing fix, not a wire-schema gap.

Whole document status: fully closed, no open cross-checks, no pending sub-questions routed to any other participant. Ready for filing.