The three map views ask for projection:'mercator' at construction, but on
Mapbox the style's own projection (globe) wins, so the map booted as a globe
while the view's state and button said Mercator, and the first "Switch to
Globe" click did nothing (rega, C2 at 0f136d25: pre/post-mapbox logs, "proj
globe -> globe"). The existing setProjectionFor ran only after a basemap
switch, never at boot.
Each view's 'load' handler (religion, rop-tree, language-families) now applies
the state's projection first; the style is ready there on both engines.
Headless, against a map whose style projection is globe: before, the map is
globe while the state is mercator; after, both are mercator.
Four lenses (send-map, lang-cluster, the cockpit's MapCell and FlowerClusters)
called setProjectionOn(m, 'mercator') straight after initializeMap(). MapLibre
throws "Style is not done loading" from setProjection before the style loads;
setProjectionOn swallows it and returns false, so on MapLibre the projection was
never set (rega, C2 at 0f136d25). Mapbox accepts it early, which is why only
one engine showed it.
A small plugin helper, src/whenStyleReady.js, runs the call at once if the style
has loaded, else on 'style.load'. Headless, against a MapLibre-shaped map that
throws before load, with the real setProjectionOn: before, it returns false and
the projection stays null; after, it is mercator, both when the style loads
later and when it is already loaded.
Every number on the country sheet is work for somebody else to fund, staff or
translate. The one thing the reader can do before closing the tab was reachable
only by guessing which pin to tap, so the sheet now names it: the largest group in
this country with nothing and nobody praying, one tap from its prayer page.
Need first, then size, and India shows the cost of that order — of its 601 groups
with nobody praying only five have nothing at all, so it offers Malai Pandaram at
8,700 people ahead of Badhai at 851,000, who already have Scripture and film. The
sub-label says which rung the reader was handed.
The count above it reads "601 / 779", never a bare 601: doxa.life publishes a page
for 1,723 of the 12,379 groups here, so nobody-praying can be said of the groups
that HAVE a page and never of the country. Zero still means zero on doxa.life's
counter, not a claim about the world.
Costs 1.05 kB: the prayer pages and the tabs catalogue were already shipped.
check:composite-freshness found stale on main: all-people-groups
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-research-map
doxa-simple-map
gospel-resources
key-cities
my-upg-100-list
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
The cleanup I promised when the fallback went in, done now that both halves are on
main (verified BY CONTENT at ccc2b1bb, not by sha).
onLegendSelect returns an EXPLICIT boolean on all four of its paths — false when the
tapestry layer is not up, true for batch mode, true for the un-isolate, true on
success. No path yields undefined, so the `typeof applied === 'boolean'` capability
check can never fall through and the inference behind it is unreachable. Three lines
become one.
WHY THE CHECK WAS WRITTEN THAT WAY, since the reasoning outlives the code: upg100-plugin
warned that a function with no explicit return yields undefined, which is FALSY — so
adopting their boolean before it landed would have read every success as a failure and
retried it. Testing the CAPABILITY rather than the version meant this worked before
their change, after it, and in any bundle built from either, with no flag day.
AND WHY NOT TO REINTRODUCE THE INFERENCE, which is the part worth keeping: I had read
!selectedKey.value as "it failed". That is a proxy for "did it ISOLATE", not for "did
it work", and the two come apart in exactly two paths — batch mode feeds the selection
union and the un-isolate clears it, and both leave selectedKey unset while having
succeeded. Retaining on that would have re-applied the zone and toggled it back OUT of
the union. upg100-plugin found it prospectively and added the boolean so the applier
could be asked instead of guessed at.
Source only; my-upg-100-list needs the rebuild.
Completes C1. Source only; studio rebuilds (rule 5B). Cut on top of C1a because it
depends on that move. Rebuild scope: country-posters, doxa-research-map,
research-dashboard, dashboard-plus-grid, gospel-resources.
resource.js existed as TWO identical plugin-local copies with no library original,
while library composables resolved it through the registry — so the library depended
on a plugin registering a module the library itself documented. Now it lives in
library/colors/ like the other eight, both copies are gone, and src/colors/ holds no
.js in either plugin. The local `registerStrategies(import.meta.glob('./src/colors/*.js'))`
in both index.js files is therefore DELETED: it matched nothing after the move, and a
glob that matches nothing reads as a deliberate override. Their now-unused
registerStrategies imports went with them. That also completes the shepherd's original
C1 instruction, which C1a had to defer for exactly this reason.
★ MY OWN PREDICTION HERE WAS WRONG, and the measurement is the only reason this is
clean. I predicted check:one-palette would FAIL after the move, reasoning that the
dashboards carry 6 of resource's 8 hexes (because 6 are SHARED with affinity-block and
language-family, which they legitimately ship) while the 2 unique ones — #20c997
jesusFilm and #2c3e50 noResources — are absent, and the gate fails a PARTIAL palette.
The gate PASSES: 9 strategies, 18 pairs. Once the module is in library/colors/,
_registry.js bundles it into every consumer, so all 8 hexes are present BY CONSTRUCTION.
I had reasoned about the pre-move bundle and assumed the rebuild would not change it.
That also corrects the diagnosis I gave earlier. simpleresearch was right that there
was no disagreement, but not that the 6 were coincidental substrings in minified
output: they are real hexes, genuinely present, shared across three strategies. The
gate cannot attribute a hex to a strategy — which is a real limitation worth knowing,
just not one that bites here.
Stale comments corrected rather than left to rot: five references across
useLegendData, researchLegendOptions, useWagfRegionsLegendData and my own
wagf-region.js header all claimed these palettes live in a plugin folder. One
parenthetical keeps the old path deliberately, as history.
GATES: check:one-palette PASS 9/18 (baselined 6/12 before C1a). check:library-index
PASS after regenerating the derived menu. check:no-free-identifiers PASS. All six
affected bundles build; their artifacts reverted.
First of the shared-library batch, and the one the shepherd put first because a
reader operates it: PosterDialog had seventeen English strings with no catalogue
entry — the whole dialog. Size, Orientation, Portrait, Landscape, DPI, Format,
both file-type options, Title, Subtitle, Slots, Scale bar, North arrow, the
footer's attribution note, Cancel, and its own heading and aria-label.
Every English string is kept exactly as it reads today, per the shepherd's
ruling, and the eleven locales are filled in. A new poster.json per locale: the
i18n layer assembles catalogues with an eager `import.meta.glob` over
locales/*/*.json, so a new area file is picked up with no index to edit.
Two of the nineteen sites needed no new wording at all — "Close" and "Legend"
already ship in every locale as buttons.close and legend.header.default, so they
reuse those rather than gaining a second spelling. That is the same rule that
sent the grid's "Clear all" to controls.reset_all: two keys for one word is how a
control ends up saying different things in different languages.
DPI is left as DPI in all eleven. It is a standard acronym, not an untranslated
string, and inventing an expansion per language would be the opposite of what
this batch is for.
★ THIS IS A LIBRARY CHANGE, so it is studio's to land under rule 5B — the
catalogue is compiled into every bundle, so every consumer needs rebuilding. I
have deliberately NOT rebuilt anything. Until it lands, every one of these falls
back to the English it reads today, which is the tStatic contract.
Measured before and after with the source audit: the shared-library
no-catalogue-entry count goes 49 → 32, and PosterDialog leaves the list entirely.
ShareButton, DataTable, LegendTools and QaCorner are the remaining batches, in
the shepherd's order.
check:composite-freshness found stale on main: all-people-groups
big-picture
cities-internet
country-posters
dashboard-plus-grid
demo-map
doxa-research-map
doxa-simple-map
gospel-resources
key-cities
my-upg-100-list
people-groups-grid
pplr-data-maps
prayer-walk-planner
regions-dashboard
research-dashboard
template-bundle
upg-neighbor
wall-of-people-groups. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
I PUT A CONFIDENT WRONG NUMBER ON THE PAGE AND AM TAKING IT OFF. The footnote
read "21 of the 2,107 are not yet in any list". That 21 is the total minus the sum
of the DECLARED `count` fields in clusters.json — a claim about that file's
bookkeeping, not about membership. I wrote it, asserted nothing about it, and
shipped it twice.
TRYING TO VERIFY IT PRODUCED A SECOND WRONG NUMBER, which is what settled the
question. Resolving every selector against the packet records gives 418 in no
geographic list, not 21. But that cache carries only TWELVE fields — no
wagf_region, no wagf_block — so 13 of the 27 selectors cannot resolve against it
at all, and deaf_predicate matches 0 of the 123 deaf records that ARE in it. Both
figures describe the FIXTURE, not the product. Nothing on disk can answer this.
AND THE QUESTION IS ILL-POSED BESIDES. clusters.json has FOUR buckets, and
allLists() reads three: the two DEAF lists (61 + 62 groups, with their own poster)
are excluded. Summing all 29 gives 2,209 against a source total of 2,106, so the
deaf lists OVERLAP the geographic ones — a deaf group in West Africa is in both.
They are a second, cross-cutting partition, organised by disability rather than
geography, which is very likely WHY allLists() leaves them out: including them in
a zone-to-list map would double-count. So "in no list" has no single answer until
someone says which partition is meant.
funnelPage already falls back to the provenance-only footnote when `unassigned` is
absent, so removing it is the whole fix: the page now says "Counts from IMB /
peoplegroups.org, captured 2026-07-16." and stops there. The share line is
untouched — "1 of 27 lists · 2% of the 2,107" is provable.
BOTH PATHS CHANGED TOGETHER. The Node generator computed the same figure; leaving
it would have made the two packets disagree about page 2.
CARDED FOR THE DRIVER, because two of these are product questions and not mine:
whether a packet counts 27 geographic lists or 29 including the deaf ones, and
whether "Central Asia + Pakistan" is a prayer list rather than a residual bucket.
It has 55 groups, contiguous=true with contiguityExempt=false, a country-code
selector, and the poster wall already files it under "Central Asia" — it is larger
than four accepted lists (Europe 55, Oceania 53, SE Asia 50, Southern Africa 39)
and reads exactly like them. Its neighbour uncovered-scatter is the genuine
catch-all: 8 groups, contiguous=FALSE with contiguityExempt=TRUE, and that
exemption flag is the tell. So "27 or 25" was a false binary and 26 is live.
Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and the
script check; the footnote no longer contains "not yet in any list"; the share
line is unchanged.
⚠ NO BUNDLE REBUILD IN THIS COMMIT — dx-shepherd's shot window forbids public/
churn while rega reshoots the README screenshots. This is source only; whoever
builds after the all-clear produces the matching artefact.
THE GAP rega NAMED AND I LEFT OPEN: "the gate passed: it counts rows, not the
row's language." It was right. When the list's label was English among Devanagari,
every assertion on that page was green — sheets, rows, bars, share line,
zero-populations. I asserted the page's STRUCTURE and nothing about whether its
prose reached the reader, which is my own standing rule turned on my own gate.
IT ONLY WORKS IN A NON-LATIN-SCRIPT LOCALE, and that is exactly why it is worth
having rather than aspiring to a general "is this translated?" test, which is not
decidable. On a hi/ar/ru/zh sheet a run of Latin letters is a proper name, a
number, or an untranslated string — three possibilities, two of them enumerable.
In de/fr/es an untranslated string looks like ordinary text and this measures
nothing, so those locales are NOT covered and the gate says so rather than
implying coverage it does not have. rega offered to chase the de/fr/es case; I
declined it for that reason, not for cost.
MEASURED FIRST, ASSERTED SECOND. Across every non-Latin packet in out/ (hi, ar,
ru, zh) exactly TWO Latin runs appear, both the data source's name. That is what
makes a floor of "nothing else" defensible instead of a guess.
THE ALLOWLIST IS THREE ENTRIES AND ALL THREE ARE DECISIONS, NOT BOOKMARKS:
IMB, peoplegroups.org — the publisher and its domain. A reader following the
citation meets the English name at the destination, so translating it would
hide the thing it identifies. Same reasoning the i18n allowlist uses for
"Open Bible Stories" and "Scripture Earth".
Project — the campaign designation. Digits break a run, so "Project 4700"
surfaces as "Project" and the numeral is never part of the match; the WORD is a
name, while the description beside it ("frontier people groups") is prose and
must be translated. That is the line check-i18n-never-translated draws around
"UUPGs", applied in the other direction.
A fourth entry must state whether it is settled or deferred: an allowlist that
does not distinguish the two turns an open defect into a verdict.
MUTATION-TESTED AGAINST THE REAL DEFECT, not a synthetic one. Restoring the
pre-5c254970 English list label on the hi sheet reports ['Project', 'frontier
people groups']; replacing a translated key with English reports ['THIS packet'];
the current sheets report clean. So it fails on the bug it was written for.
Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and
this new script check — 6 non-Latin packets actively checked, 12 correctly out of
scope. The gate also prints the runs it found, so a reader can see what it
measured rather than trusting a silent pass.
TWO FINDINGS FROM rega'S ?list= RENDER, and the first one is a hazard I created
in the same commit where I argued against it.
I MADE PACKET_LIST FAIL CLOSED because "a packet that showed the wrong list is
printed and handed to someone" — and then wrote the frontier packet to the SAME
FILENAME as the default-list packet of that zone and language. rega measured the
result: out/packets/ held frontier packets under the DEFAULT names, beside an `ar`
file that was still the default list, and nothing in the name could tell them
apart. Guarding the input while leaving the output ambiguous protects against
asking for the wrong list, not against being unable to tell what you got.
Fixed both ways. The list is in the FILENAME
(asia-china-tibet-autonomous-region.frontier.hi.html) and STAMPED INTO THE
DOCUMENT as data-packet-list, because a filename can be renamed, copied or
emailed while the packet itself should be able to say what it is. Same reasoning
as data-packet-build: out/ is gitignored, so a sweep grades whatever files happen
to be there, and a packet that cannot describe its own provenance makes an
accidental set indistinguishable from a current one. The gate prints it too.
THE LIST'S LABEL WAS ENGLISH IN EVERY NON-ENGLISH PACKET. "Project 4700: frontier
people groups" sat among Devanagari labels in a Latin serif face, because it came
verbatim from library/lists/lists.json — which carries ONE label per list and has
no locale dimension at all. The gate passed because it counts rows, not the row's
language.
It is translated in packet-strings.json rather than by restructuring the shared
lists.json: that file is the packet's OWN catalogue, where a new language is a new
block and never a code change, and it degrades per key like every other string. An
unknown list still falls back to the data's English label.
THE NUMERIC DESIGNATION STAYS — "Project 4700" / "مشروع 4700" / "4700 计划" —
because it names the campaign, while "frontier people groups" is prose. That is
the same line check-i18n-never-translated draws around "UUPGs": translate what a
bilingual reader would not dispute, keep what is an identity.
rega'S MEASUREMENTS, all confirming predictions I could only compute: 6 pages, 37
of 43 records, rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37, bars
1080/640/415/184/152/26 identical in en and hi, share "2.1% of the 1,745" with no
"1 of 27 lists", provenance-only footnote, fail-closed rc=1 on an unknown list.
And the number I most wanted: share-to-footnote clearance 314px en, 222px hi — so
hi+frontier, the tightest combination that exists (128px rows, gap down to 147px),
still clears by 222px and no label wraps.
Verified: all 18 packets in out/ pass sheets, rows==bars and zero-population.
The 11 laos packets FAILED the zero-population assertion until regenerated — they
predated the fix, which is the gate distinguishing stale artefacts from current
ones exactly as data-packet-build was added to do. Default path unchanged (43
groups, 7 sheets, 5 rows). frontier renders 6/6 in en, ar and hi with the label in
each script. check:packet-strings-parity PASS with the 5 new keys.
A fixed 7 s wait shot the upg-100-list counter over a bare basemap: that leaf paints 9.5-16.5 s after
load (rega, 3 cold loads at fc2fa2e4). Every map a shot's page or frames create is now recorded (an init
script on the engine global's Map.prototype.setStyle, as in rega's probe), and the capture waits until
every visible map has its style and tiles loaded and has drawn features on OUR layers (GeoJSON sources;
the basemap's are vector tiles), capped at 60 s with a WARN naming the unready map. Each shot's `wait`
becomes a 3 s floor, and the full fallback for a surface with no map. Not browser-run here (browsers are
rega's): the next shot run is the proof.
rega measured it at fc2fa2e4 (3 cold loads, tokenless, judged by rendered features on the leaf's own
layers): first paint +9.5 to +14.3 s, idle +12.1 to +16.5 s. The 7 s wait then network-quiet plus
1.5 s made the English shot a coin toss; the Arabic shot of the same leaf (12 s) only won it. Both go
to 20 s. A stopgap: the lasting fix is to wait on the map (style + tiles loaded, rendered features)
rather than a timer.
research-dashboard and dashboard-plus-grid imported '../doxa-research-map/src/colors/…' — another plugin's
private code — which is how two copies of the same palette drifted (C1a moves them to library/colors).
The gate refuses any relative import that resolves into plugin-designs/<other>/ below that bundle's root;
a composite mounting another bundle's PUBLIC profile .vue (the capability-4 pattern) stays allowed. It
FAILs on main (the 4 palette imports C1a removes) and PASSes with C1a; --self-test in gates:post.
First palette migration from SPEC-LIBRARY-CURATION.md. Source only; studio rebuilds
(rule 5B — library/ plus four other owners' plugins).
WHY THIS ONE FIRST: the duplication was not idle. doxa-research-map's
affinity-block.js carried A018 'Tibetan Peoples' and A019 'Himalayan Peoples' —
live on the pray-tools API — with colours measured to WCAG 1.4.11's 3.0:1 non-text
floor (A013 5.28 unchanged, A018 7.69, A019 3.53; sibling separations 2.61/1.46/1.79).
country-posters' copy covered only A001–A017, so 134 rows (6.4% of 2,107) fell to the
grey fallback #95a5a6 — the "bunch of grey colours" report. exp-shepherd confirmed
that code path headlessly. So the correct copy wins with no judgement call, and the
move FIXES country-posters rather than changing it.
FOUR CONSUMERS, NOT THE TWO THE SPEC COUNTED, and finding that is the sharpest
argument for the migration: research-dashboard/index.js and dashboard-plus-grid/index.js
each imported `../doxa-research-map/src/colors/affinity-block.js` — a cross-plugin
RELATIVE import into another plugin's PRIVATE folder. Both now import @map/colors/.
Only the build found this; no gate names cross-plugin reach.
THE GLOB STAYS in both plugins' index.js, deliberately, and is documented there:
resource.js is still plugin-local pending C1b, so deleting the glob would unregister
it and colour those pins by the soft fallback — the same silent wrong-colour class
being fixed here. Deleting the two STALE FILES is what stops the stale copy winning.
THE REJECTED-CANDIDATE NOTE MOVED ABOVE `export const PALETTE`, and this is a real
constraint rather than tidying: check:one-palette slices from that export to END OF
FILE and matches every hex, despite its own comment claiming it reads "only the
PALETTE export". #AF7AC5 was rejected for A019 at 2.95 — under the accessibility
floor — and mentioned below the export it would have been DEMANDED of every consumer
bundle. The note keeps the reasoning without creating the obligation; the gate bug
itself is reported separately since internal/ is not mine to edit.
The legend needed no change and that is by design: useAffinityBlockLegendData resolves
via getColorStrategy(COLOR_MODES.AFFINITY_BLOCK) — the same strategy the pins use — and
reads labels from the data, so map and legend gain A018/A019 together. Its comments
claiming the strategy lives in a plugin folder were corrected; they now describe where
it actually is.
GATES: check:one-palette PASS — 8 strategies, 16 bundle/palette pairs (baselined at 6
and 12 before the move, so a red would have been mine). check:library-index PASS after
regenerating the derived menu. check:no-free-identifiers PASS. All four consumer
bundles build; their artifacts reverted for studio.
MapLibre's and Mapbox's own control labels stayed English under ar/es (rega's row 30). Both engines take
a `locale` map of their UI strings at construction; useMapInstance now passes one built from the new
mapui catalogue (17 strings x 11 locales, parity PASS) for the reader's language. The keys are the
engines' own (MapLibre 5.24's list, plus Mapbox's gesture-hint names); an engine ignores the rest.
Built doxa-simple-map carries the translations; check:i18n-treeshake PASS.
SPEC-LIBRARY-CURATION.md's first migration, in the order the shepherd approved.
Source only; studio rebuilds (rule 5B — library/ plus eight other owners' plugins).
THE ASYMMETRY, which is why 12 sites each solved it or failed at it separately:
Mapbox getProjection() -> { name } setProjection('mercator')
MapLibre getProjection() -> { type } | undefined setProjection({ type: 'mercator' })
Passing the Mapbox shape to MapLibre THROWS. Measured on regions-dashboard, where
the throw aborted the camera fly so a region dashboard never framed its region.
WHAT THE 12 SITES WERE DOING:
· NINE passed a bare string, so on MapLibre the projection silently never changed.
Eight swallowed the throw in a try/catch; doxa-simple-map.vue:1244 did NOT, so
there it threw uncaught.
· TWO had solved it independently and differently: pplr-data-maps'
setProjectionFor() (instance-tested — the correct one, and the shape adopted)
and regions-dashboard's local pair (config-derived, which is weaker: it trusts
what the page was configured with rather than what the map IS).
· ONE probed by exception, trying the object form and falling back on throw.
The helper reads the engine from the INSTANCE, never from config, because a page can
hold a map built by the other engine. Both functions return something observable —
setProjectionOn returns whether it applied, projectionOf returns null rather than
throwing — on upg100-plugin's argument that a precondition which bails without
recording it is indistinguishable from the request never arriving.
pplrData.setProjectionFor stays as a delegating named export so its three views need
no change.
GATE: _chk-c2-projection-helper-equivalence.mjs, 20 assertions. It runs all THREE old
patterns and the helper against the same stub maps and compares outcomes, rather than
testing the helper alone. Controls prove pattern A really does fail on MapLibre and
leave the projection untouched, and that the bare string really throws — so the
"equivalence" claim has something to be equivalent to. A NEG control catches a helper
that reports success it did not achieve.
★ I DESTROYED 984 LINES OF ANOTHER TEAM'S CODE GETTING HERE, and only the build caught
it. My import-inserting script did `s[:at] + IMPORT` and never appended `s[at:]`, so
every file where the import was ADDED rather than extended was truncated at its last
import line: MapCell.vue -458, FlowerClusters.vue -334, lang-cluster.vue -192.
check:no-free-identifiers PASSED on all three, because a truncated script block is
still valid script. Restored from HEAD and redone with an assertion that a migration
must change the line count by exactly +1 and never remove a line — which is the check
that would have caught it in the first second.
Verified after: all 9 affected bundles build, both gates green, and a per-file line
delta on all 13 changed files with the only negative being the 2 lines I deliberately
deleted from RegionCountryMap when its local pair became an import.
library/lists/lists.json carried one English label per list, so every surface
that showed a list's name printed English in every locale (reported by upg100,
which worked around it inside the packet's own strings).
- A `lists` catalogue area, library/i18n/locales/<lc>/lists.json: a label and a
definition for each of the five lists, in all 11 locales.
- listLabel(id, t) / listDefinition(id, t) in peopleGroupList.js resolve
lists.<id>.label through the caller's translator (useI18n's t, tStatic, or any
key -> string), accept ids and aliases ('700'), and fall back to the
registry's English. The module still imports no i18n runtime, so it stays pure
and testable in Node.
- The registry's labels are now documented as the English fallback only, and the
doxa list gains the definition its catalogue entry has.
- check:list-prop-unit (24 cases, was 17): the Arabic and German catalogues come
through, a missing translator or a raw key falls back to English, the English
catalogue must equal the registry word for word (a drifted registry label fails
it, naming the list), and every locale must carry both fields for every list.
- documentation/reference-universal-props.md: read names through listLabel.
Headless: list-prop-unit, i18n-parity, i18n-never-translated,
no-free-identifiers and universal-props pass. No consumer calls listLabel yet
(the packet has its own strings), so nothing renders differently until one does.
The province table and the map's province layer both read gb-IND-ADM1.geojson,
which is the UPG-100 list's curated 34-unit partition — not a country's states.
Eleven groups came out with no province because they were standing in states that
partition does not draw. Both now read the complete ADM1 that landed with the
internet work (36 states, geoBoundaries via DataMeet India): 998 assigned becomes
1,005, 44 province rows become 46, and the rows name Indian states.
Four groups are still unassigned and each one is legible: Great Andamanese and the
Sentinelese sit on islands whose simplified coastline their point falls just
outside, and Jing and Pana are on China's Vietnam and Laos borders, outside its
twelve-province subset. They are listed with their coordinates, not dropped.
The staged-assets gate now also asserts the map and the table read the SAME
geometry. Staging could never catch this: both files were staged.
check:composite-freshness found stale on main: cities-internet
country-posters
key-cities
my-upg-100-list
regions-dashboard. Rebuilt, tree + SRI
regenerated; the landing precondition (fresh, one folder, SRI) passed in main-capture scan.
build -> tree -> fix:sri, run outside the lock as the shepherd asked, so the
landing itself is only a merge, a scan and a push. No conflict markers in the
output (checked).
ITS ONLY WITNESS WAS MY OWN HARNESS. The browser path honours the universal
`list` prop — upg-100-list.vue calls applyPeopleGroupList() at every fetch site —
but library/scripts/posters/packet.mjs had NO list support at all. So the
non-default-list branch of funnelPage, including the six-row shape where a list's
own total is inserted before the reader's row, could not be produced by the only
pipeline rega can drive. I had unit-tested it through a script I wrote about it,
which is exactly the state I have been objecting to in other people's gates.
rega offered to shoot a ?list= packet and there was no command to give them.
PACKET_LIST=<id> closes that. Rendered for the first time, and it is right:
rows 12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37
bars 1080 / 640 / 415 / 184 / 152 / 26 (six rows, six bars)
label "Project 4700: frontier people groups" between the network's list and
the reader's 37
share "This packet is 2.1% of the 1,745" (no false "1 of 27 lists")
foot provenance only, no coverage claim
row gap auto-adjusted 218 -> 147px for the extra row; last row stays 14px so
the share still reads as its conclusion.
IT READS THE MEMBERSHIP FILE FROM DISK, not through loadListMembership(), which
fetches from the asset pool over HTTP — there is no server in a Node build. Same
file, same slugs, same counts.doxa.
AND IT FAILS CLOSED: an unknown or empty list exits non-zero rather than quietly
producing a packet of every group. peopleGroupList.js makes the same choice for
the same reason ("a map that silently showed all 2,107 while an embed asked for
Project 700 would be showing the wrong list with no sign of it"), and a packet is
worse than a map — it is printed and handed to someone.
⚠ buildFunnel() IS NOW A FUNCTION, NOT A CONSTANT, because it depends on a list
resolved after the records load. It mirrors upg-100-list.vue's packetFunnel()
exactly: the default list is the only one the zone catalogue describes, so it is
the only one that may claim "1 of K lists" and an unassigned count.
── AND A CORRECTION TO MY OWN VERIFICATION ─────────────────────────────────
"Numerals untouched" is about DIGITS, not GROUPING, and I verified the digits and
assumed the rest. rega measured the rendered hi sheet: Indian grouping,
"8,28,85,63,969 लोग" (lakh/crore 2-2-3) rather than Western 3-3-3. That is
CORRECT hi-IN formatting, so changing it would be worse — the real risk was never
the grouping but INCONSISTENCY, the same number printed two ways in one document.
Measured and it is consistent: num() is the only formatter in the renderer, so the
funnel's populations and the card pages' populations group identically — confirmed
against the rendered hi packet (card populations 1,70,000 / 3,37,000 / 8,10,000,
Indian-grouped like the funnel's). Only values at or above 100,000 can distinguish
the systems at all; my first check classified "1,400" and read a mismatch that did
not exist.
Verified: default path byte-identical (43 groups, 7 pages, 5 rows, 5 bars); all
four fixtures PASS with rows==bars; PACKET_LIST=frontier gives 37 of 43 records
and 6 pages; check:packet-strings-parity PASS; vite build clean, bundle unchanged
(the change is Node-side).
A LIVE WRONG NUMBER, not a future risk. The universal `list` prop is already on
main (e3d25ff6) and this profile already honours it — applyPeopleGroupList()
filters the rows at all four fetch sites, packetRecords included — and the
membership files are on disk. So under ?list=frontier-500k the packet's rows were
the 71 network groups that are Project 700, while the funnel took its denominator
from the last step of vision-funnel.json and printed "of the 2,107". Off by a
factor of 30, with nothing on the page looking broken.
Measured from data/people-groups/lists/*.json (counts.doxa, what a Tier A map
shows): frontier-500k 71 · frontier 1,745 · unengaged 1,929 · unreached 2,082 ·
the default 2,107.
THREE NUMBERS WERE WRONG, NOT ONE, because all three descended from one total:
the share's denominator; the footnote's "N are in no list" (total minus assigned);
and "1 of 27 lists", which is a property of the UPG-100 ZONE CATALOGUE, not of the
active list — the zones partition the default list and nothing else.
A non-default list now gets its own step, labelled with the list's own label, so
the narrowing does not jump from one list's total to another list's packet.
AND EACH CLAUSE IS SUPPRESSED SEPARATELY, because they are provable separately.
A first pass dropped the whole share line whenever listCount was absent, throwing
away a true statement ("5.4% of the 1,745") to avoid an unprovable one. The
footnote splits the same way: provenance holds for every list, coverage does not.
⚠ The footnote is NOT split by parsing the translated sentence — hi ends in "।"
and zh in "。", and reading translated prose for structure is the trap
check-packet-render's own comment warns about. It is a second key.
⚠ And `funnel.unassigned ?? 0` would have printed "0 of the 1,745 are not yet in
any list" — a confident falsehood whose zero reads as a completeness claim.
── AND THREE FINDINGS FROM rega'S RENDER, none of which I could see ──────────
"0 people" PRINTED ON THE READER'S ROW OF EVERY PACKET. Number(null) is 0 (only
Number(undefined) is NaN), so `Number.isFinite(Number(x))` admitted the
deliberate `population: null` marker as the number zero. I asked "is this a
finite number?" when the question was "did anyone give me a population?" — the
same presence-vs-value confusion as inferring "did it work" from a field that
answers "did it isolate". Zero is suppressed too: a people group with no people
is missing data wearing a number's clothes.
THE SHARE LINE READ AS PART OF THE FOOTNOTE. Measured: 218px below the reader's
row, 42px above the footnote, because the last row kept the full inter-row gap.
Proximity is the only thing grouping them, so an even gap grouped the share with
the wrong element. The last row's margin is now 14px: share 14px under its row,
288px clear of the footnote.
THE GATE ASSERTED PAGE 2 AND PRINTED NOTHING ABOUT IT. rega: "a reader cannot see
WHAT it asserted." A silent pass is the mirror of a printed-but-unasserted number
— the check is real and the evidence invisible. It now reports rows, bars and the
share line, and gained two assertions: every row must have a BAR (the reader's
step had none, so the funnel never visibly narrowed to "yours"), and no row may
print a zero population.
Verified: all five list cases render their own true numbers — doxa keeps the full
claim, unreached/frontier/unengaged get their own step and a percentage with no
false "1 of K", frontier-500k reads "100% of the 71" with the duplicate row
suppressed (a step that repeats its neighbour is not a step). The four fixtures
are byte-for-byte unchanged on the default path, so the list-awareness is purely
additive. check:packet-strings-parity PASS with the three new keys. Both new gate
assertions mutation-tested in en and ar: removing a bar is DETECTED, injecting a
zero population is DETECTED. 5 rows = 5 bars, 0 zero-population rows, "0 people"
absent. Six-row layout recomputed: fits with 318px headroom, 206px if every label
wraps. BUNDLE=my-upg-100-list vite build clean.
NOT re-rendered in a browser: rega measured the previous state and these are
responses to it, so the new geometry wants one more look.
TWO CHANGES, both from other agents catching things I could not see from here.
1. NAMESPACED, on upg100-plugin's point that the host element is SHARED SURFACE.
data-select-zone-state / -applied become data-doxa-select-zone-state / -applied.
A partner page embedding this element could reasonably own a generic name like
the first pair, and a diagnostic has no business squatting one on someone else's
element. Renamed AFTER rega's re-shoot rather than before: their probe polls
these names, so renaming mid-flight would have burned a measurement to save a
few minutes.
2. THE RETAIN CHECK WAS WRONG IN BATCH MODE, caught prospectively by upg100-plugin
before it bit anyone. applySelectZone inferred failure from !selectedKey.value,
but onLegendSelect feeds the selection UNION in batch mode and returns without
ever setting selectedKey — isolating is not what it should do there. So correct
behaviour read as a failure, and retaining on it would have re-applied the zone
and toggled it back OUT of the union. Un-isolating (node == null) has the same
shape: the un-isolate IS the action, and selectedKey is null afterwards by design.
They have given onLegendSelect a boolean return for exactly this (fb076c8b on
packet/vertical), where false means one thing only: the map was not ready.
FORWARD-COMPATIBLE, deliberately. That change is not on main yet, and a function
with no explicit return yields undefined — which is FALSY. Depending on it today
would read every success as a failure and retry it, which is precisely the trap
they warned about. So the boolean is authoritative when it IS a boolean, and until
then the old inference stands with batch mode excluded.
Their design note is the part worth keeping: the early return stays and stays
side-effect free, because other code depends on it BAILING; a boolean makes the bail
OBSERVABLE without changing when it happens. That is the smallest change that fixes
the epistemic problem rather than the control flow.
The India layer drew on UPG-100's curated partition (34 of 36 units), so
Mizoram and Nagaland had figures but no geometry. studio staged the complete
ADM1 beside it (data/boundaries/gb-IND-ADM1-full.geojson: gbOpen
IND-ADM1-1811400, DataMeet / ECI, CC BY 2.5 IN, with its .meta.json). The
curated file is untouched.
- connectivity-map.vue fetches boundaries/gb-IND-ADM1-full.geojson.
- The crosswalk maps Mizoram -> "Mizoram" and Nagaland -> "Nāgāland" (the
boundary file's spelling) instead of null. The generator validates against the
full file, and the table's meta names it with its licence.
- The regenerated table has 36 areas drawn and none missing, so the legend's
"no boundary yet" line no longer shows (it renders only when that list is
non-empty).
Headless: build and tree OK; both boundary files are staged; the fill
expression (maplibre-gl-style-spec) colours 36 of 36 states (Mizoram women
67.6, Nagaland women 49.9).
A SILENT `return` ON A PRECONDITION IS INDISTINGUISHABLE AT THE RENDERED LAYER
FROM THE REQUEST NEVER ARRIVING. The "Show on map" interop bug had three layers
stacked under one symptom: an element event with no replay, an id resolved
against legendNodes that had not loaded, and this function no-op'ing on a missing
'tapestry-fill' layer. Each fix looked sufficient because all three produced the
same nothing. regions-plugin fixed the first two by retaining the request and
flagged the third as mine, since it has three call sites — correctly, and this is
it.
IT NOW RETURNS A BOOLEAN. true when the selection was applied, false when it
bailed on the readiness check.
THE EARLY RETURN STAYS, AND STAYS FREE OF SIDE EFFECTS. It is a documented
readiness gate — the comment at :630 calls it "THE REAL READINESS SIGNAL" — and
other code relies on it bailing rather than throwing or queueing. A boolean is
the smallest change that makes the bail OBSERVABLE without altering when it
happens.
TWO EXITS RETURN true THAT MIGHT LOOK LIKE FAILURES, and the distinction is
load-bearing rather than cosmetic:
- batch mode fed the selection UNION instead of isolating. That is the correct
behaviour there, not a failure to isolate. A caller retrying because it read
`false` would toggle the zone back OUT of the union.
- the un-isolate (node == null) IS the action, and it succeeded.
And the success path returns true EXPLICITLY: an implicit fall-through yields
undefined, which is falsy, so a caller checking the result would read success as
failure and retry — the opposite of what this value is for.
No caller reads the value yet, so the change is purely additive and cannot
regress: verified all four call sites (:945 un-isolate, :1539 paintAll's
re-apply, :1638 and :1641 in the select-zone path) ignore it today. regions'
applySelectZone is where it becomes useful — it already retains on no-effect by
checking whether selectedKey held, and can now ask the applier directly instead
of inferring.
BUNDLE=my-upg-100-list vite build clean.
THE HALF I OWED. check-packet-render gained --expect-funnel last commit so a
packet with no funnel page can fail — but nothing passed the flag, and I said so
to regions-plugin while advising them not to reuse the pattern as though
declarable meant covered: "I have traded an invisible defect for a visible
obligation, which is better but not the same as fixed." This is the obligation.
check-packets-all now passes --expect-funnel for every packet it sweeps.
WHY IT CAN BE UNCONDITIONAL. Every packet in out/ is produced by
library/scripts/posters/packet.mjs, which always passes FUNNEL to buildPacket. So
a packet there without page 2 was either generated before the funnel existed or
by a caller that dropped the argument, and this sweep should say both out loud.
Verified across all 16 packets in out/ — the 4 fixtures, the Arabic tibet, and
all 11 laos locales, which I regenerated for this: 95 groups, 9 sheets each (was
8), 95 photos, page 2 present in every one. So the requirement matches a property
the pipeline actually holds rather than one it aspires to.
THE ESCAPE HATCH IS DELIBERATELY AWKWARD: PACKETS_ALL_NO_FUNNEL=1, which prints a
warning. A legitimate funnel-less host is a real state and deserves to be
recordable, but it must be a decision someone takes rather than a default that
quietly tolerates a missing page.
Verified: node --check clean; the arg construction exercised both ways (flag
present by default, absent under the env var). All 16 packets satisfy the sheet
arithmetic with 0 failures. Hindi laos re-checked as the longest-label locale —
5 rows, 5 bars, 0 Devanagari digits, longest label 51 chars so the wrap slack is
not even consumed.
NOT re-run end to end: check-packets-all spawns check-packet-render per packet,
which launches Chromium, and the Playwright policy reserves browsers to rega.
That is also how I learned this wrapper starts browsers — I ran it earlier
without realising and reported it.
TWO FIXES FROM A BROWSER RUN I COULD NOT DO MYSELF. rega measured page 2 in
print media and found the opposite of the defect I feared: no overflow, but the
four global steps ended at y~330 of 1056 while "43 · are in THIS packet" sat
alone at y~965, leaving the middle ~60% of the sheet empty so the reader's own
step read as a detached footer. And it had NO BAR, so the funnel never visibly
narrowed to "yours" — a funnel whose final term is not drawn as part of the
funnel fails at the one thing it is for.
So the five steps are now ONE list with the reader's included, and it has a bar
like the others: 1080 -> 640 -> 415 -> 184 -> 26px, the last two tinted so the
network's list and the reader's list read as one thought. The 26px floor is what
keeps the smallest list a visible sliver rather than a hairline.
THE ROW RHYTHM IS DERIVED FROM MEASURED ROW HEIGHTS, not guessed. A first
attempt subtracted a magic 118px as "about one row" and left 418px of dead space
at the foot — rega's emptiness, just redistributed. Summing the real heights
closes it to ~298px.
AND IT KEEPS DELIBERATE SLACK, because two runtime facts are unknowable at
layout time: whether a step carries a population line, and how many LINES its
translated label wraps to. Arabic and Hindi labels are far longer than the
English, so LINE_SLACK reserves one extra wrapped line per row. Computed: 298px
headroom normally, 196px even if EVERY label wraps an extra line. Tight-packing
this page would trade "60% empty" for an overflow in the locales I cannot eyeball.
── AND A GATE HOLE A NEAR-MISS EXPOSED ──────────────────────────────────────
regions-plugin reported, unprompted, that a merge resolved with
`git checkout --ours` silently deleted the printPacket call site supplying the
funnel data — 60 lines — AND EVERY GATE STAYED GREEN. They filed it as merge
discipline. It is also gate design, and that half is mine: the funnel page is
OPTIONAL by design (buildPacket omits it when the arg is null), so a packet
without page 2 is not malformed, just smaller. My own choice made the deletion
invisible.
--expect-funnel closes it. The wrong fix is to always require the page: the 11
pre-funnel laos packets and any host with no funnel data would go red, and a
false red costs as much as a false green. Instead the CALLER, which knows whether
this packet was built with funnel data, declares it. An absence is then either
DECLARED (no flag, fine) or a FAILURE (flag passed, page missing) — never a
silent nothing.
Verified: all 16 generated packets satisfy the sheet arithmetic, 0 failures,
including the 11 laos packets that have no funnel page. fixture-tibet renders 5
rows with 5 bars ending in 43. Arabic regenerated: 7 sheets, dir="rtl", 5 rows,
5 bars, ZERO Eastern-Arabic digits on the sheet. Mutation-tested --expect-funnel
three ways: baseline+flag PASS, funnel-sheet-deleted+flag FAILS, deleted without
the flag PASSES. Flag filtered from the positional read, so a bare
--expect-funnel is not eaten as the packet path (that trap has bitten this repo
three times). BUNDLE=my-upg-100-list vite build clean, +141 bytes vs main.
NOT re-verified in a browser: check:packet-render needs Chromium, reserved to
rega. The geometry above is computed, not rendered — the print-media layout is
exactly what rega's eyes caught that my arithmetic did not, so this wants one
more look.
The served gb-IND-ADM1.geojson is the 100-list's curated partition (Mizoram and Nagaland excluded,
listId-tagged), so a map colouring every state had data for 36 and shapes for 34. data/boundaries/
gb-IND-ADM1-full.geojson is all 36 from geoBoundaries gbOpen (boundaryID IND-ADM1-1811400, DataMeet /
Election Commission of India, CC BY 2.5 IN), with source and licence in gb-IND-ADM1-full.meta.json;
generate-tree stages both, and the dev server serves data/ directly. Reproducible with
library/scripts/boundaries/source-full-adm1.mjs (coordinates rounded to 3 decimals: 1.84 MB). The curated
file is untouched.
Country-level internet data is not good enough for India. This adds the first
sub-national layer, from the one open, machine-readable source verified for it:
NFHS-5 (2019-21, IIPS / MoHFW), "women / men aged 15-49 who have ever used the
internet (%)", urban / rural / total, for every state and UT (CSV transcription
of the state fact sheets, CC BY 4.0; SPEC-GRANULAR-INTERNET.md).
- data/internet/: the two NFHS-5 internet rows (with provenance, licence and
upstream commit), plus a strict crosswalk from source names to the shipped
gb-IND-ADM1 shapeNames.
- plugin-designs/cities-internet/scripts/build-adm1-internet.mjs (`bun run
internet:adm1`) writes data/tables/internet-by-adm1.json. It is GENERIC, one
row per iso3 x adm1 x measure x sex x area, with meta naming measures,
sources, boundary files and the national figures, so go-int and other plugins
can reuse it. It fails on an unmatched name or a value outside 0-100: a name
is never guessed.
- The map draws India's states in a blue ramp (0-25-40-55-70%+), leading with
women, with a Women / Men toggle. A tapped state shows both halves, urban /
rural / total. Its own legend block names the measure, survey, years and ages,
and says it counts people, not subscriptions, so it is never read as the
World Bank country bands. India's national band is taken out under its states.
- The shipped boundary file is the UPG-100 partition (34 of 36 units): Mizoram
and Nagaland have data but no geometry. They stay in the table and the legend
names them.
- 8 new strings under connectivity.adm1, in all 11 locales (library change:
consumers need rebuilding).
Headless: build and tree OK, and the table and gb-IND-ADM1 are staged under
assets/. The fill expression validates with maplibre-gl-style-spec, and all 34
shipped states get a colour (e.g. Bihar women 20.6 -> 0-25, Delhi men 85.2 ->
70+). A crosswalk poisoned to a wrong shapeName fails the generator. Passing:
universal-props, no-free-identifiers, i18n-parity, i18n-never-translated,
profiles-mount, composites-mount, no-orphan-gates, script-flags, quoted-scripts.
Not rendered in a browser.
25 stale shots (derived from check-shot-freshness on this tree), shot with internal/readme-shots.mjs
in its tokenless default ('token: none, by design (engine auto -> MapLibre)'). 24 images changed,
1 byte-identical (cap4-upg100-poster-wall). check:shots PASS (25 of 26 compared; deploy1-iframe
skipped as not a /dx-maps/ leaf).
Localizing the grid's data (ae5e27c3) would have broken the language link
silently, and the shepherd asked me to prove the coupling with a test. Measuring
it first showed the coupling should not exist.
MEASURED against the live API, en vs ar, 2,107 records:
primary_language LABEL 864 names in en, 864 in ar, ZERO shared → localized
primary_language VALUE 864 codes, ALL 864 shared → stable
religion / wagf_region VALUE also identical across locales
So the fix is not "make both halves fetch the same locale and test that they do".
It is to link on the form that does not move. Region already links on a slug and
religion on a family letter; language was the one facet still travelling as a
display name.
The map now emits `languageCode` alongside `language`, the composite carries
{value, label} for language as it always has for region, the GRID is driven by
the code (valueMatches compares the normalized value as well as the label, so a
code matches in every locale) and the MAP is still driven by the name, because
its legend selects by name. Each half gets the form it can actually use, and
neither depends on the other's locale.
★ THIS TREE HAD ALREADY PAID FOR THIS ONCE. deriveLanguageFamily in useMapData
carries a comment about the 2026-06-22 locale colour bug: deriving a family from
the localized label collapsed every pin onto one bucket, and the fix was to use
primary_language.value. Same trap, one facet over, three months later.
check:linked-vocabulary now runs per LOCALE as well as per provider — en as the
control, ar as the case that would have broken in silence — and asserts the
language facet with a poison case (a language that cannot exist must NOT narrow
the grid). It is a browser gate, so rega runs it; I have not.
★ AND check:no-free-identifiers CAUGHT MY OWN BUG IN THIS COMMIT on its first
outing: I wrote `rows.find(...)` in the emitter where no `rows` was in scope. The
gate named the file and line before anything was built. That is the class of
mistake that broke both composites yesterday, found in seconds this time.
It claimed to read "only the PALETTE export" but matched every hex from `export const PALETTE` to the
END OF THE FILE, comments included. A later note such as "REJECTED #AF7AC5, scored 2.95, under the 3.0
floor" (regions' affinity-block.js, being prepared) would have obliged every dashboard to ship a colour
rejected on WCAG grounds. paletteHexes() now slices to the export's matching closing brace and reads
hexes only inside string literals. --self-test (check:one-palette:self-test, gates:post) plants a hex
in an inline comment, a block comment, a trailing comment and a later export, and requires only the
two real values back. The verdict on main is unchanged (PASS, 6 palettes).
d6bf410a (a merge) committed conflict markers into 10 built files under public/ (big-picture,
dashboard-plus-grid, research-map, research-dashboard, both manifests) and every precondition passed:
a built bundle with markers throws on load while its SRI hash still matches. Step 0 now scans the
COMMIT's tree with git grep (1.3 s, every file, whatever the working tree holds) and refuses.
Run on d6bf410a it names exactly those 10 files; --markers-self-test builds a throwaway commit object
holding a textbook conflict under public/ and requires the scan to refuse it (PASS).
9384c1ba (simpleresearch) captures the instance in setup, so research-map.vue's
map-filtered is dispatched again, and the gate correctly reported the leftover
LATE_KNOWN entry as stale. It failed on main 688ba6cf with exactly that one
finding. With the entry removed, the gate passes and enforces the rule for
research-map.vue again. The two reflectDir entries stay: those sites are
unchanged.
Two defects rega found at ?lang=ar and ?lang=es (row 30).
★ THE CHROME TRANSLATED AND THE DATA DID NOT. The `&lang=` on the people-groups
fetch was built from props and profile-config only, never from the URL — so a
reader who asked for Arabic got Arabic buttons around English country, affinity
and religion names. That reads as a half-finished translation rather than the
wiring bug it is. It now uses the library's own localeFromUrl() — imported, not
re-implemented — so the fetch and the chrome cannot disagree about what the URL
asked for. Same ladder regions uses, and a host passing languageCode still wins.
★ AND THERE WAS NO RTL AT ALL. In ar the cards, the total and the filter bar laid
out left-to-right around Arabic words. createAppI18n reflects an RTL locale onto
document.documentElement, which is right for a page it owns and useless for an
element dropped into somebody else's — which is this grid's whole job. `dir` is
set on the HOST now, like both composites: it takes effect through the inherited
CSS `direction`, and inheritance crosses shadow boundaries, so one attribute
turns the element and everything it renders. Reflected on mount as well as on
change, because the common case is a page that loads in Arabic and never changes.
⚠ ONE CONSEQUENCE I AM NOT HIDING. Localizing the DATA introduces a coupling the
linking did not have: the composite's language facet travels as a DISPLAY NAME,
so the map and the grid must now fetch in the SAME locale for that link to match.
Region links on a slug and religion on a family letter, so both are unaffected;
language is the one that depends on it. check:linked-vocabulary compares the two
halves' vocabularies and would catch a mismatch, but it runs in English today —
extending it to a non-English locale is the honest next step, and it is not done.
country-languages, translation-credits and the SUN index each wrote straight to
disk. Each had a floor of its own — 150 countries, "something joined", 3 verified
pages — and a floor only catches a collapse. The run that half-succeeds clears it
and overwrites good data with less of it, which is the exact failure the guard was
written for after a rate-limited FCBH run lost a third of its video scopes.
All 16 fetchers now write through it. Re-ran two of them against the live
catalogues: 254 countries and 929 credited codes, byte-identical but for the date,
and with the floor forced high the guard refuses and leaves the file alone.
★ THE PRECONDITION WAS RIGHT AND I COULD NOT READ IT. It refused four landings
saying "a committed composite is not what its source builds" without naming one,
and my own freshness run said clean. Running check-composite-freshness at the
precondition's exact scope threw instead: a SyntaxError parsing
public/dx-maps/manifest.json — '<<<<<<< HEAD' at position 2317.
Ten built files carried committed conflict markers: both manifests and eight leaf
pages. They came in through my own merge resolutions. 'git restore --ours' on a
conflicted path gives MY side — and once markers were committed once, my side had
them, so every later merge carried them forward and the precondition kept
refusing a tree it literally could not parse.
Regenerating from source clears all ten. The lander now checks for markers before
committing built output, because a gate that cannot parse its input fails in a way
that looks like the thing it was checking for.
The province data was readable only inside the map's own sheet. It is a tab now:
44 provinces of India and China, the same file the map reads, so the two cannot
disagree — and unlike the sheet it sorts and exports.
Every row still says placement is approximate. A people group is assigned to the
province its map point falls in, and one point stands for a people who may live
across several.
The credit line said who translated the Scripture and called them "the likeliest
people to ask", then stopped at a name. 838 of the 929 credited groups now reach
that organisation's own homepage from the sheet.
The URL does not come from eBible's homeDomain field, which is still dropped: it
comes from a hand-screened list of 16 organisations, every one fetched on the run
that wrote the table. Where a credit names two, the one it LEADS with wins, so
"Pioneer Bible Translators and The Word for the World International" reaches
Pioneer and not the other way round.
National Bible Societies hold 89+ credits and are deliberately linked to none of
them: several UBS members publish editions carrying the deuterocanonicals, so
whether a Society contact sits inside the 66-book filter is a ruling to be made,
not one to assume. They keep the name without the link, and the count says so.
A contact is screened as somewhere to write to, not as a publisher this map reads
text from — two separate lists, so wycliffe.org appearing as a scripture link is
still a failure. Four organisations answered a datacentre IP with a bot challenge
while serving browsers normally; those are proved by robots.txt and the table
records which method proved which.