Commit graph

259 commits

Author SHA1 Message Date
doxa-maps-88655c70
4eec165a2b Merge remote-tracking branch 'origin/main' into shepherd/merge-b3
Some checks are pending
build-gate / build (push) Waiting to run
2026-09-24 14:24:48 +00:00
doxa-maps-88655c70
64fed43449 packet: stamp how a packet was built, into the packet
Some checks are pending
build-gate / build (push) Waiting to run
out/packets/ is gitignored, so check:packets-all grades whatever .html files
happen to be sitting there — an accidental set, not a current one.

On this machine that was 11 freshly generated packets. In
doxa-maps-plugin-studio, which shares main with this worktree, it was two
NOPHOTOS=1 layout probes of mine from earlier ticks, and the sweep was RED there
for anyone who ran the wave. The gate was honest about each file it checked; the
set it checked was accidental.

The underlying problem is that a fast layout-iteration build and a shippable one
were byte-indistinguishable. The packet carried lang and dir and nothing about
its own provenance, so the only way to tell them apart was for the person
running the gate to remember and pass PACKET_EXPECT_PHOTOS=0.

packet.mjs now stamps data-packet-build with the flags it generated under, and
the gate reads it. The GENERATOR writes it rather than the gate inferring it:
had the gate guessed "no photos present, so probably a probe", it would have
excused genuine photo-fetch failures. Tested explicitly — a packet stamped
"full" whose map svg is then stripped STILL FAILS. The stamp records intent and
cannot excuse a defect the build did not declare.

Deliberately not a timestamp. An mtime staleness rule was considered and
rejected on evidence from this same session: packet-strings.json was 303s newer
than the packets while byte-identical to HEAD, because a probe had restored it
from backup, so an mtime rule would have failed a correct tree — the exact
mistake fixed two commits ago in the library-index gate. Content comparison is
not available here (regenerating hits a live API and takes minutes), so the
build records its intent instead of the clock.

The studio sweep goes from "2 checked, 0 passed" to "1 checked, 1 passed", with
the packet self-declaring build="nophotos nomap". Env vars stay as an override
for pre-stamp artefacts, and the declared-deliberate note now names whichever
source actually declared it rather than always crediting the env var.

All 11 locales stamp "full". i18n re-verified with a partial block (1 string,
3/12 points, 6/12 rhythms): wheel 6 translated and 6 English, 12 point rows with
6 translated, 0 blanks. Wave 34/34, packets-all 11/11 here and 1/1 in the studio.
2026-09-24 14:21:45 +00:00
doxa-maps-88655c70
445131cf94 Merge main into bundler-3/work — settle the 21-file conflict, keep both campaigns
Only 2 files conflicted in source: package.json and its CLI doc, both taken to
main's refactored gate pipeline (gates:pre/gates:post/build:bundles) after
verifying every check the old inline chain named still runs there. The other 19
were generated: public/assets/tables mirrors keep main's deliberate deletion,
and public/doxa-maps is regenerated by the build that follows.
2026-09-24 14:16:47 +00:00
doxa-maps-88655c70
588d0da48a packet gate: look inside the PDF it was already making
Some checks are pending
build-gate / build (push) Waiting to run
check-packet-render has called page.pdf() for a long time and then read only
MediaBox and /Type /Page. Eight blank sheets of the right size would have passed
every assertion in it.

That matters because the packet exists to be PRINTED — it is a standalone file
with photos and fonts inlined precisely so it prints offline — and until now
every check was on-screen. A print-only CSS rule can strip content that the
on-screen checks still see.

The first mutation is the argument for the whole change. Adding
`@media print{.card img{display:none}}` left the on-screen line reading
"95 photos, 0 typographic" — every pre-existing check green — while the PDF
carried 2 images instead of 85. The second, hiding sheet contents in print,
fires all three new assertions at once on a packet that is still 8 correctly
sized pages.

Images are compared against DISTINCT srcs, not cards. A PDF de-duplicates
identical images, and laos has 95 cards and 95 <img> but only 85 distinct
sources, because ten groups share a portrait — a fact about the upstream photo
set, not a rendering fault. Comparing against 95 would have reported "8 photos
dropped at print time" on a correct packet. I nearly did exactly that.

A scare investigated and cleared rather than reported: the PDF names only
DejaVuSerif, which looked like the vendored Devanagari face failing to reach
print. It is not. Under print media emulation the stack still resolves to
"Noto Serif Devanagari", and the same string measures 210px in it against 420px
in plain serif, so the face is genuinely in use and the PDF names are Chromium
subset aliases. Corroborated independently: hi is the largest packet at 3.2MB
with the fewest faces at 7, which is one big vendored face, not a fallback. A
ToUnicode-based check was discarded after calibration found only 30 Latin
codepoints in a known-good English PDF — an incomplete parser whose
zero-Devanagari result proved nothing.

All 11 locales embed fonts and carry every photo. i18n re-verified through the
new assertions with a partial block (2 strings, 4/12 points, 5/12 rhythms):
2.8MB, 11 fonts, 87 images, wheel 5 translated and 7 English, 12 point rows,
0 blanks. The studio's declared map-less photo-less packet still passes — the
image assertion skips at 0 distinct sources, so the opt-outs compose. Wave 34/34.
2026-09-24 14:10:59 +00:00
doxa-maps-88655c70
e0fd61eb3e Gospel presentations in 94 languages, from Cru's API rather than its parked domain
Every pool here so far is Scripture, film, Bible video or a course. None is a GOSPEL
PRESENTATION — the short translated thing a person actually opens with someone. Cru
publishes those, in the same organisation that makes the JESUS Film, so the doctrine
question is settled the same way the brief settles it for the film. Only their gospel
tracts are included, from a fixed allow-list; the lessons and survey tools are not.

IT CLOSES NO GAP AND SAYS SO. All 94 of these languages already have something, so this
is depth rather than coverage and moves no tier. Tiers are byte-identical at
2901/4767/4711. It reaches 3,270 pins and 3.97bn people.

godtools.org, the obvious host, IS A PARKED DOMAIN. It answers 200 to everything with a
114-byte body, and rendered, every URL — including /zz/nosuchtool-xyzzy — reads
"godtools.org is parked free, courtesy of GoDaddy.com". Shipping links there would have
sent people to a GoDaddy ad.

The real front-end is knowgod.com, and it does serve per-language tracts: /en/kgp reads
"Knowing God Personally", /sw/kgp reads "Kumjua Mungu Kibinafsi". But ONLY once
rendered. By fetch it is a uniform 3,864-byte shell for every URL valid or not — the
third host in this bundle with that shape, after jesusfilm's old ?languageId= and
live.bible.is.

So existence is proved from Cru's own API and never from the page: a (language, tool)
pair exists exactly when the resource's latest-translations intersect that language's
translations. Checked against the browser before being trusted — en/kgp and sw/kgp both
intersect and both render real localised text, and the bogus pair does not intersect. In
band, the verifier's controls are now 7 negative all non-verified and 7 positive all
verified, with a bogus knowgod pair among the negatives. All 94 URLs verified.

Clause I keeps its closed set: Gospel presentation only on knowgod.com, and knowgod.com
only that type. Poison self-test still 12/12. Screenshot read: German shows six distinct
resource types, including "Gospel presentation - Knowing God Personally".
2026-09-24 13:51:56 +00:00
doxa-maps-88655c70
d561e35d33 packet gate: a map one-pager with no map on it is not a map one-pager
Some checks are pending
build-gate / build (push) Waiting to run
Deleting the map svg from page 1 entirely left this gate reporting
"map: palette resolves, not black — PASS, map painted".

mapFaults queried `document.querySelector('.sheet svg')` — the first svg
ANYWHERE in the document. With page 1 empty the query silently retargeted to the
PRAYER WHEEL's svg two sheets later, and the wheel has no CSS variables to leave
unresolved and no black fills, so both checks passed vacuously. The sheet count
stayed 8 because the empty sheet still exists.

A fallback selector turns an absence into a pass: when the thing being asserted
about disappears, the query finds a different object that happens to satisfy the
checks — and here the gate printed a stronger claim than it had evidence for.
"page 1 = map one-pager" is the first line of the packet spec.

Now scoped to sheet 0, which the page order fixes as the map page. And it
asserts the map paints GEOGRAPHY, because palette-resolves and not-black are
both properties a map with zero country shapes satisfies perfectly. Geography is
paths whose `d` exceeds 200 chars — a coastline, not an icon. The floor of 5 is
set from a measured spread rather than guessed: laos 13, brazil 18,
middle-east-core 55, tibet 69, uncovered-scatter 74, varying with how many
countries a zone spans.

Mutation-tested in three shapes: no svg fails naming page 1; an svg present but
drawing nothing fails at "only 0 geography path(s)"; the good packet passes at
13. All 11 locales report 13 — the map is data, not text.

Syncing the gate surfaced a second thing. doxa-maps-plugin-studio ships 0 poster
html files against wt-packet's 28, and packet.mjs deliberately "still builds
(cover page without the map) rather than failing" when out/posters/<zone>.html is
absent. That soft-fail is intentional but it was invisible: the degraded packet
has 8 sheets, an <svg> present (the wheel's), and no marker at all, so it is
structurally indistinguishable from a good one while page 1 promises a map and
delivers an empty sheet. PACKET_EXPECT_MAP=0 follows the PACKET_EXPECT_PHOTOS
precedent — fail by default with the remedy named, pass when the degradation is
declared.

Also fixed the summary line, which read "map painted" unconditionally and so
described a declared map-less packet as having a painted map. It now reads
"NO map (declared)". The summary is the part a reader quotes.

i18n verified with a 2-key partial block: page 1 renders the translated keys,
falls back to English elsewhere, 0 blank runs. Wave 34/34, packets-all 11/11.
2026-09-24 13:45:39 +00:00
doxa-maps-88655c70
6c60af555f library-index gate: ask whether the menu drifted, not whether a file is older
Some checks are pending
build-gate / build (push) Waiting to run
`touch library/components/legends/SemanticTreeLegend.vue` — zero content
change, nothing in git status — turned this gate red. So did a git checkout, a
rebase, and any editor that rewrites a file on save. It cried wolf twice in one
build session while the regenerated doc was byte-identical both times, and the
only available response was to run the generator and commit nothing.

An mtime answers a different question than the gate's own name asks.

This is the SECOND time this exact class was fixed here. The first was a SCOPE
error: the gate walked 2.3x more of library/ than the generator reads, so a
comment in a build script turned it red and named that script as the reason the
component MENU was stale. Narrowing the scope fixed that instance, but the
mechanism was still mtime, so the class survived inside the narrowed scope. The
lesson recorded then applied again word for word — a gate that fails for a
change it can prove is irrelevant teaches the reader to regenerate on reflex,
which is how a staleness check stops being read.

It now regenerates into a throwaway tree and diffs the bytes.
generate-library-index.mjs hardcodes its output path and derives ROOT from its
own location, so it cannot be told to write elsewhere — but copying the SCRIPT
into a temp tree whose library/ is a SYMLINK to the real one relocates its ROOT
without modifying it. The generator runs unmodified against real sources, writes
somewhere disposable, and an interrupted run leaves nothing behind. Verified
byte-identical to a real `bun run library-index` before this was written, and
the generator was confirmed deterministic first — a content diff would be
useless otherwise.

Comparing output also makes the scope self-enforcing: a change outside
components/, composables/ and utils/ cannot alter one byte of the menu, so the
INDEXED_DIRS list the mtime walk needed is gone.

Mutation-tested both ways, because a gate that never fires is worse than a noisy
one. Deleting a catalogued row fails with the line number and expected content.
Adding a real new library component fails — that is the drift this exists to
catch. Touching 40 library files, the rebase case, passes. Wave 34/34.

Swept the five other internal/*.mjs that reference mtime: only
check-shot-freshness also decides on one, and there it is correct — a PNG cannot
be cheaply regenerated and diffed, and a rebuilt bundle is meaningfully newer.
Left alone deliberately.
2026-09-24 13:29:58 +00:00
doxa-maps-88655c70
53fea6ee38 packet gate: a prayer wheel with no words on it is not a prayer wheel
Some checks are pending
build-gate / build (push) Waiting to run
Blanking every wheel segment label and regenerating left check:packet-render
reporting "12 segments, 11/11 rhythms, content intact — PASS" on a wheel that
was a twelve-colour pie chart.

Both printed numbers stayed true, which is why nothing caught it. The twelve
segments are <path> elements and survive their labels being removed. And the
rhythm assertion searched document.body.innerText, where the SAME twelve rhythm
words are rendered TWICE in every packet — once per wheel segment, once as a
prayer-point heading. So "every rhythm word appears in the document" was
satisfied by the points page alone and said nothing about the wheel.

That is the self-consistency-vs-completeness family again, with a new twist:
when two surfaces share one vocabulary, a document-wide string search cannot
attribute a hit to the surface being asserted. The fix is scoping the query to
the surface, not a better word list.

wheelLabels counts the non-numeric <text> runs inside the .wheel-title sheet's
own svg, excluding the twelve "5" pips and the "5 minutes x 12" footer. It
counts ELEMENTS, NOT DISTINCT WORDS, deliberately: `praise` and `praise_end`
collapse to one word in English — which is exactly why the existing line reads
11/11 and not 12/12 — and that collision is locale-dependent, so a distinct-word
count would be a different number per language.

The Driver's spec names the wheel as one of the packet's four sections, so 12
labels is a contract. Mutated packet reports 0/12 and fails; restored reports
12/12.

i18n verified at the rendered layer with a partial locale block (4 of 12 rhythms
translated): the wheel painted four XX- labels with the other eight falling back
to English and zero blank — and the split pair is visible, segment 1 English
PRAISE beside segment 12 XX-PRAISE_END, which is what per-key resolution looks
like. All 11 locales regenerated with 95 photos each: check:packets-all 11/11,
every one 12/12 labelled, including RTL ar and Devanagari hi. Wave 34/34.
2026-09-24 13:23:57 +00:00
doxa-maps-88655c70
a177f98aff legend: the controls no longer read their own i18n key aloud
Some checks are pending
build-gate / build (push) Waiting to run
The legend's toggle button announced the literal string "aria.toggleLegend" to
screen readers. Not in translated locales only — in English too. vue-i18n echoes
the key when a lookup misses, and `toggleLegend` was absent from every catalog
while its two siblings in the same file, backToLegend and closeDetailView, were
present. Six call sites across LegendDesktop.vue and LegendMobile.vue, desktop
and mobile alike.

aria-label is the one string a sighted tester never sees. Every other
untranslated string in this tree got caught because somebody looked at the
screen; this one is only ever spoken aloud, so it survived every visual QA pass.

A second instance turned up in the same sweep with the opposite shape:
SemanticTreeLegend hardcoded 'Collapse legend' / 'Expand legend' into both title
and aria-label with no t() at all — correct English, and untranslatable. One key
missing FROM the catalog, one string never routed TO it.

Adds toggleLegend, collapseLegend and expandLegend to all 11 aria.json catalogs
and routes the hardcoded pair through t(). check:i18n-parity already guards them.

New gate smoke:aria-labels asserts BOTH shapes, because either assertion alone
is insufficient: "no raw keys" passes a hardcoded English label forever, and
"labels differ across locales" passes a raw key, since a key is identical in
every locale. It detects a raw key by SHAPE — dotted, camelCase, no spaces —
which is locale-independent and works on the Arabic and Chinese pages where no
English word list could.

Mutation-tested twice. Removing the catalog key fails with the raw key named, on
all four viewport/locale combinations. Hardcoding one label back to English
initially PASSED: the differ count dropped 6 to 5 and nothing asserted on it —
the same "a number printed but not asserted on is decoration" shape this tree
keeps rediscovering — so the gate gained a LOCALIZED_FLOOR ratchet and now fails
that too.

Verified the fix generalizes beyond the profile it was found on: country-posters
reports 182 of 190 accessible names localized, 0 raw keys. Rebuilds the six
bundles that embed a legend; check:sri confirms every leaf matches. Wave 34/34.
2026-09-24 13:16:18 +00:00
doxa-maps-88655c70
fb7ed4566d Browser gates now prove the server is serving THIS checkout
The gospel-resources gate was found adopting whatever answered on the default port,
which on this machine was an unrelated process from a different worktree. It reported
on a build that was not the one on disk: first a PASS, then a mount timeout that read
exactly like a regression in code nobody had touched. "Answers 200" and "is serving
this checkout" are different questions, and only the second one is the gate's job.

That fix was local to one gate. The same default sits under the rest of them, so it is
now one shared helper, internal/_serves-this-tree.mjs, fingerprinting
public/doxa-maps/manifest.json — which generate-tree rewrites on every build and which
names every bundle and profile in the tree. Different bytes means a different build:
another worktree, or a server someone forgot to restart. Both produce the same lie.

Wired into verify-browser-all, which hands ten gates their base URL and would have
passed the wrong tree to all of them, and into check-a11y-button-names. Both now refuse
with a NOT MEASURED exit rather than producing a confident wrong answer. The gospel
gate keeps its behaviour of starting its own server instead.

Verified in both directions: against the foreign 4173 each prints "serves a DIFFERENT
build than public/" and exits 2; against this tree the aggregator proceeds to its ten
gates and the gospel gate passes.

ONE REPORTED NUMBER WAS WRONG BECAUSE OF THIS. The a11y result recorded earlier in this
flight, "810 buttons, 810 with an accessible name", was measured against the foreign
tree. Re-run against this checkout it is 825 buttons, 825 with an accessible name,
still passing across 21 leaf pages. The conclusion held; the evidence had not been
about this build.

Audited the rest of this flight's browser evidence rather than assuming: the 41 probe
scripts in _qa-cycle all target port 5399, which is served by
serve-strict --directory <this worktree>/public, and the manifest served there is
byte-identical to the local one. Those results stand.
2026-09-24 12:54:35 +00:00
doxa-maps-88655c70
6af75895c7 legend: the info popovers explain the map in the reader's own language
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked for the per-tab info buttons to stay — "the tab itself gives
you a little information button that will explain what a UPG-100 list is."
They existed, and they were English-only in every locale.

SemanticTreeLegend rendered `tab.info` RAW in all three places it appears (the
button's title=, and the popover body) while the tab LABEL beside it went
through the catalog. So a Spanish reader saw a translated chip next to an
English explanation of what that chip means — and the explanation is the part a
reader who does not read English actually needs. The three UPG-100 tab labels
were absent from tabs.json entirely, so they fell back to hardcoded English too.

Measured on the served leaf before changing anything: ?lang=es changed 2 of 76
rendered strings ("POPULATION" -> "POBLACIÓN") and left all three tabs and all
three info popovers in English. That positive control is what proves the locale
switch WORKS and the strings were simply never catalogued — without it this
reads identically to a broken ?lang=.

tabInfo() mirrors tabLabel exactly: `legend.tabInfo.<i18nKey|id>`, te() guard,
falling back to the hardcoded English so a tab with no catalog entry renders
unchanged rather than blank. The v-if stays keyed on the raw `tab.info`:
whether the affordance EXISTS is a property of the tab definition, while its
TEXT comes from the catalog.

Catalogued in all 11 locales, labels and info. check:i18n-parity already guards
them — deleting one zh key fails the build by name.

New gate smoke:legend-info renders the REAL served page and clicks each info
button. check:tabs-localized could not see any of this: it tests 7 hardcoded
research-map tabs and REIMPLEMENTS tabLabel inside page.evaluate, so it proves
the mechanism works rather than that a given profile is localized — and it never
looked at `info` at all. Mutation-tested: reverting the popover to the raw prop
fails with "the catalog is present and unreached".

Partial-translation contract verified by deleting 2 of 3 fr info keys and 1 of 3
fr labels: translated keys render French, untranslated fall back to English,
nothing blank, and labels and info resolve INDEPENDENTLY.

Rebuilds the six bundles that embed the legend so the tree stays internally
consistent; check:sri confirms all 55 script references match. Full wave 34/34.
2026-09-24 12:49:04 +00:00
doxa-maps-88655c70
77ac7e3436 The two largest gaps on this map were Chinese varieties of a resourced language
Read top-down, the gap list sent you to "Chinese, Jinyu - 60,310,000 people - nothing
found", then "Chinese, Gan - 41,280,000". Both statements are true about those ISO
codes and both are misleading as priorities: ISO 639-3 files them under the
macrolanguage zho beside cmn, Mandarin, which has Scripture, film, audio and training
here. Measured across the whole map: 84 pins covering 140M people sit at "nothing
found" while a sibling variety of their own ISO macrolanguage carries resources.

Those groups now carry a related-variety note, on the pin sheet and inline in the
ranked list, because the list is the thing that gets read top-down. 63 codes have one.

WHAT IT DOES NOT SAY. It is not a claim of mutual intelligibility, and the wording
refuses to imply one - spoken Jinyu and spoken Mandarin are not mutually intelligible;
what they share is a written standard. Only ISO's own relation is asserted, the reader
is told exactly that, and it changes NO tier and closes NO gap. Tiers are byte-identical
at 2901/4767/4711. The chips still read "none found", because that is still true.

The macrolanguage table is fetched from SIL rather than transcribed, and written
through the snapshot guard like every other catalogue.

Also corrected: the gap list header still said "Audio has never been searched for any
of them". That stopped being true when Faith Comes By Hearing was added, and a stale
caveat misleads exactly as much as a stale number. It now says audio HAS been searched
and these groups have none there either, with GRN still named as the one pool nobody
can look in.

And a gate defect that produced three runs of false signal. verify-gospel-map adopted
any server answering 200 on the default port. Port 4173 turned out to be held by an
unrelated python3 process from another worktree, so the gate reported on a build that
was not this one - first a PASS, then a mount timeout that looked exactly like a
regression in code that had nothing to do with it. Answering 200 is not the same
question as serving this tree. It now fingerprints the served bundle against the bytes
in public/ and refuses to adopt a server whose build differs, starting its own instead.
Proven: it now prints "serves a DIFFERENT build than public/ - not adopting it",
starts its own, and passes.

Screenshot read: the ranked list shows "related: Chinese, Mandarin has resources"
under Jinyu, Gan and Min Bei, and correctly nothing under Deaf Chinese or Deaf
Pakistani, which have no macrolanguage sibling.
2026-09-24 12:44:24 +00:00
doxa-maps-88655c70
f129294609 Clause L: a verification record that stops being re-run now fails the gate
Eleven clauses checked that the surfaces AGREE with each other. None checked that any
of it was still true. "Link checked" is a claim about the present tense, and a table
that quietly stops being re-run keeps making that claim on evidence that could be a
year old while every consistency clause still passes. A dead link is the failure a
field worker actually hits, so this is not bookkeeping.

Clause L fails when a shipped URL carries no check date at all, or when the oldest
shipped link was last checked more than 45 days ago, and the message names the exact
command to fix it. Re-running is cheap and resumable now, so the failure is an
instruction rather than an obstacle.

Proven against real data, not only the poison harness: deleting the check date from
one shipped URL is caught ("1 shipped URL(s) carry no check date"), and backdating one
by 120 days is caught ("last checked 120 days ago, past the 45-day limit"). Both
restore clean. Poison self-test now 12/12.

Also checked and deliberately NOT changed: the Arclight harvest keeps a language only
when counts.featureFilm >= 1, and it was worth asking whether that discards coverage.
Of 2,330 catalogue languages, 2,309 already have a feature film. Only 15 carry just a
shortFilm or a series, and exactly one of those - nmg Kwasio, 2 pins, 60k people - is
spoken by a group here with nothing else. Widening the filter would mean typing a
short film as "Film" beside the JESUS Film for a single language. The filter stays.

The registered description still claimed "6/6 clauses must fail"; corrected to 12.
2026-09-24 12:31:36 +00:00
doxa-maps-88655c70
e8ad1d56e6 packet: a partial translation renders 12 prayer points, not 3
Some checks are pending
build-gate / build (push) Waiting to run
packet-render.js resolved the prayer points with `L.points || EN.points`.
That is a whole-block fallback when the key is ABSENT, and TRUNCATING when a
translator has done some of the 12 — a 3-element array is truthy. Measured at
the rendered layer with a deliberately partial locale block: a page headed
"12 Prayer Points" containing THREE, points 4..12 silently gone, including the
Mark 5:1-20 passage point 4 prints in full. Nothing blank, no error raised.

Invisible in all 11 shipping locales because every one of them is complete.
It would have appeared on the next language added — and "a new language is a
NEW BLOCK, never a code change" only holds if a PARTIAL block degrades per key,
since half-finished is the normal state of a language in progress.

Now merges per point and per field against English: English fixes the LENGTH
and supplies every missing field, so a point translated for `text` but not
`sub` keeps the English `sub` — and keeps its NUMBER, which the merge supplies.

The sheet-sum invariant could not see this: a 3-point page is still exactly one
sheet, so every number check-packet-render printed stayed correct while nine
points were missing. It now measures and asserts the count. Mutation-tested —
reverting the fix prints "prayer points : 2 / 12" and fails.

All 11 locales regenerated with photos and swept: check:packets-all 11/11, each
12/12 points, including RTL ar and Devanagari hi. Renderer, generator and gate
synced to doxa-maps-plugin-studio, which also lacked the .points-title and
.wheel-title test hooks the new assertion needs.

Also wires check:capability-exemplars into gates:post (33 -> 34 steps). It reads
public/doxa-maps/manifest.json, a build artifact, so ordering mattered: `tree`
writes the manifest and is &&-chained immediately before gates:post, and the
gate SKIPs rather than fails when the manifest is absent. Full wave: 34/34.
The quoted gates:post line in the README is re-synced in the same commit,
because check:quoted-scripts goes red on the drift this commit creates.
2026-09-24 12:20:38 +00:00
doxa-maps-88655c70
0e0b142719 A half-finished catalogue fetch can no longer overwrite a good one
The FCBH fetcher read per-fileset video scope for 1,666 languages and that file was
committed. A later run, while live.bible.is was rate-limiting, produced 1,103 and
overwrote the good file with the degraded one. Nothing failed, nothing warned, and the
artefact silently lost a third of its content. It was caught only because git status
happened to be read, and the fix was a git checkout.

That is the third time this shape has appeared here:
  - a VERIFY_LIMIT smoke run replaced the full verification table with 30 rows;
  - dropping a dead link removed it from the corpus, so the next run forgot it was
    dead and the build put it straight back;
  - this one.
Every snapshot in this bundle is built from a network read, and a network read has a bad
day. The dangerous case is never the run that throws - that leaves the old file alone -
it is the run that half-succeeds and writes anyway.

scripts/_snapshot-guard.mjs now stands in front of every catalogue write. A snapshot may
grow freely and may shrink a little, but losing more than a tenth of the previous run is
treated as a failed fetch: the existing file is left untouched and the script exits 1.
It is deliberately not an equality check, because catalogues do genuinely lose entries
when a publisher retires something, and a gate nobody can satisfy gets switched off.
SNAPSHOT_FORCE=1 is the one loud way past it.

Proven by replaying the exact failure: feeding it the 1,103-entry version refuses the
write, names the real numbers (563 fewer, 13.8%, below the 90% floor) and leaves the
file byte-identical to the good one. SNAPSHOT_FORCE=1 writes it. An equal write passes.

All six fetchers are wired: ebible, arclight, arclight-share-urls, door43, zume, fcbh.
Re-running zume, door43 and ebible for real passes the guard at 52, 208 and 1,237 codes
and produces byte-identical data, so the guard perturbs nothing.

The one exception is the checkpoint write in the resumable share-urls fetcher, which
starts from the existing file as a cache and can only grow. It must now declare itself
with a CHECKPOINT-WRITE-OK marker, so the exemption is a statement rather than an
oversight.

New gate check:snapshot-guard fails if any fetch-*.mjs writes OUT directly without that
marker, or never calls writeSnapshot at all - a guard that is easy to bypass is a guard
that will be bypassed. It refuses to run against an empty file list rather than passing
vacuously, and --poison injects a raw write and requires the check to catch it. Chained
into build:unlocked and its doc copy kept in sync.
2026-09-24 11:59:48 +00:00
doxa-maps-88655c70
510f3d30d4 The backlog-status enum was too narrow, not the nine cards wrong
Some checks are pending
build-gate / build (push) Waiting to run
This gate keeps the AGGREGATE honest — a prose status makes every open-count wrong
while each card still READS correctly, so the error hides in the total. That matters
to this loop directly: every tick starts by reading status counts to pick a card.

MEASURED across 633 cards: EIGHT distinct status values are in use and EVERY ONE IS
A SINGLE WORD. No prose remains anywhere in the field — which is exactly what the
original 130-card migration achieved. The nine cards this gate was failing carried
legitimate TERMINAL states the enum simply predated: superseded (5), deferred (2),
decided, verified-already-true.

★ REWRITING THEM TO "done" WOULD HAVE DESTROYED REAL DISTINCTIONS to satisfy a list.
A SUPERSEDED card was replaced by another; a DEFERRED one was consciously postponed;
neither was finished. Losing that information to make a checker green is the
opposite of what this gate is for.

OPEN vs CLOSED is the property that matters, and all four additions are CLOSED:
open = todo + doing + in-progress (66), closed = done + these four (507),
blocked (60).

MUTATION-TESTED BOTH WAYS, so widening did not weaken it: a prose status
("done — reconciled against main, see notes") still FAILS by name — the original
defect — AND an unagreed one-word status ("wontfix") ALSO fails, so the set stays
CLOSED rather than becoming "any single word".

Wired into gates:post (32 -> 33) after confirming it reads only backlog JSON. The
doc that quotes gates:post is re-transcribed in the same commit.

Build: 7/7 pre, 23/23 bundles, 33/33 post, exit 0.
2026-09-24 11:58:39 +00:00
doxa-maps-88655c70
c4fbc469ac Bible video from FCBH, with the real book scope on every link
The previous change left video_date out because the column does not say what the video
IS. Establishing it took measurement, and the first samples were misleading. Three
video-only filesets all rendered ...S2DV pages opening on "Intro & Garden of Eden" with
Drama/Non-Drama controls, which suggested one Old Testament series. Reading
GET /api/bibles/<bible_id>, which returns the exact books array in one call, for all
1,669 video languages says otherwise:

    209  27 parts, Matthew to Revelation          (the whole New Testament)
    116  27 parts, San Mateo to Apocalipsis       (same, Spanish editions)
    110  12 parts, Intro & Garden of Eden ...     (Covenant, Old Testament)
     27  Mark      26  Acts      23  Luke
    1,006 distinct scopes in all

Generalising from three samples would have described the bulk of it exactly backwards.
Every link therefore ships its own scope rather than a shared description, and the 3
languages whose book list could not be read were dropped: an undescribed video is not
shipped.

It is typed "Bible video", never "Film". Film on this map means the JESUS Film, and
bible.is itself treats the two as different - its pages call a separate Arclight
endpoint, /api/arclight/jesus-film/languages?iso=..., to find it. So Bible video lifts a
group off "nothing found", which is true, and contributes nothing to the Scripture+film
tier. 1,666 languages carry it: 287 as their own link reaching 4,375 pins and 5.76bn
people, and 1,379 merged into the Scripture link whose media list now reads "audio,
text, video" instead of advertising half of what the page holds.

Verification for bible.is switched from a body-size heuristic to their own API. The page
answers 200 for any id, so the first version compared body sizes against a shell
measured at run time. That was a heuristic on a number the site controls, and it stopped
working when their WAF began answering HEAD with 403 and a 520-byte body for real and
bogus alike. The run-time calibration caught exactly that and ABORTED rather than mark
thousands of links verified - which is the whole reason it was measured and not
hard-coded. The API needs no heuristic: /api/bibles/OKEBSN is 200, /api/bibles/ZZZZZZ is
404. It is a tenth of the weight, it describes the same fileset the page renders, and
GET is used because HEAD is what the WAF blocks.

Also fixed: a fetch with no timeout hung the fetcher for thirteen minutes with no
output. A stall looks exactly like slow work, so every fetch in it now carries a
timeout, and the scope lookups run with bounded concurrency and 403 backoff.

6,404 links screened, 6,557 verified, 74 broken and dropped, 5 uncheckable. Screenshot
read: German shows "Bible video - 12 parts, Intro & Garden of Eden to After the Forty
Years / End Credits" as its own chip, distinct from "Film - link checked".
2026-09-24 11:47:37 +00:00
doxa-maps-88655c70
28ce04f6ac check:done-evidence knew one card schema and failed the best-evidenced cards on the board
A done card with no record is the reporting failure this loop exists to avoid — so a
gate that mis-identifies WHICH cards lack a record is worse than none: it teaches the
next agent to pad the field the checker reads.

★ THE THREE FLAGGED CARDS WERE AMONG THE BEST-EVIDENCED THERE ARE. Each carried
one_line, why, and a `verify` block naming the probe, its assertion count and its
negative tests — "74 files match byte for byte, all three drift classes exit 1",
"9 assertions, all pass", "two new assertions, both negative-tested".

The gate looked only for `bluf` or `fields`. MEASURED across 443 done cards: 40 carry
`verify`, 41 carry `one_line`, and ALL THREE flagged cards had one or both. `verify`
is if anything the STRONGER signal — it names how the claim was checked, which is
what evidence means here.

Fixing the CARDS by adding a redundant bluf would have been gaming the check. Fixing
the GATE to recognise the evidence already there is the honest repair.

★ THE BASELINE IS NOW EMPTY. All ten exemptions reported STALE — every one had since
gained evidence — so the list exempted nothing while implying ten cards still owed a
record. An EMPTY set is not an unset one: every done card must now carry evidence
with NO exemptions, and the gate fails outright for a single card that does not.
Keeping ten satisfied ids would mean the same today and quietly permit a regression
the moment someone reused an entry. Same reasoning as emptying the i18n DEBT map.

MUTATION-TESTED both directions: a done card with NOTHING in any of the four fields
FAILS by name; the same card with ONLY a `verify` entry PASSES.

Wired into gates:post (30 -> 31) after confirming it reads only backlog JSON, which
the build does not mutate. The doc quotes gates:post, so it is re-transcribed in the
same commit — the self-referential update noted last tick, handled in one pass this
time.

Build: 7/7 pre, 23/23 bundles, 31/31 post, exit 0.
2026-09-24 11:38:17 +00:00
doxa-maps-88655c70
659ca6e244 FCBH text as well as audio, and 207 text-only filesets stop claiming to be audio
The FCBH integration used only audio_date. Their rows also carry text_date, and it is
unambiguous - they publish Bible text on bible.is. 698 codes carry text where this map
had no text Scripture at all, reaching 2,176 pins and 1.38bn people. Codes go 2,192 ->
2,400 and the gap falls 2,927 -> 2,908.

ONE LINK PER LANGUAGE, NOT ONE PER MEDIUM. A bible.is fileset is a single page carrying
whatever media it has, so two chips pointing at the same URL would be noise. The link
now states what it carries: 1,465 codes audio+text, 677 audio only, 207 text only.

That last number was a live falsehood. Because the builder never copied the media field
across, all 2,349 bible.is rows were typed "Audio Scripture" - including 207 filesets
with no recording at all. A group with text-only Scripture was being sent to look for a
recording that does not exist. Both the chip and the table type now follow the media.

Ranking is by media count, then AUDIO PRESENT, then recency. The middle term is not
decoration: without it a newer text-only fileset outranks an audio-only one on date
alone and the language silently loses its recording, which is the medium an
oral-preference group actually needs. Measured while it was missing: 3 codes lost audio.

video_date is deliberately left out. The column does not say what the video IS - a Luke
film, a sign-language edition, something else - and counting it as "film" beside the
JESUS Film would assert something nobody here has checked. 400 codes with no film today
carry one, so it is worth establishing later, but not by guessing.

Clause I now accepts BOTH Audio Scripture and Scripture on bible.is, because a fileset
really can be either, while keeping the set closed and keeping Audio Scripture wrong on
every other host. Proven by poisoning both directions: Film on bible.is and Audio
Scripture on ebible.org are each caught. Poison self-test 11/11.

Also verified, not assumed: the brief's requirement that a pin or country click filters
the table. iso=deu shows 4 of 5,889, country=IND shows 502 of 5,889. It works; an
earlier reading of mine that suggested otherwise was measuring the wrong thing.

All 6,117 links screened and verified. Screenshot read: Ninigo (ssg) shows "Scripture,
text - 2012 Wycliffe Bible Translators, Inc." where it would have claimed audio.
2026-09-24 11:26:10 +00:00
doxa-maps-88655c70
482e6d4ca0 Wire the seven gates that could run, and make wave membership visible
Some checks are pending
build-gate / build (push) Waiting to run
MEASURED: 70 of 100 check/verify scripts were in package.json but in NEITHER
gates:pre nor gates:post. I had fixed this same "present but unwired" failure three
times for individual gates without ever asking how many others were in the state.

★ THE GATE THAT EXISTS TO CATCH THIS WAS ITSELF UNWIRED. check:no-orphan-gates
asserts every gate-shaped file is REACHABLE — named in package.json or imported —
and it passed, 87/87. But reachable and WIRED are different properties, and
conflating them is exactly how 70 scripts sat outside every build. A script entry
means a human CAN run it; wave membership means it RUNS.

Tested each unwired gate standalone: 7 pass with no precondition and were simply
never wired (now in gates:post, 22 -> 29 steps, +1.5s on a ~150s build); 6 FAIL on
pre-existing documentation drift nobody sees — check:verification-tags alone lists
61+ untagged cards — which is its own project, and wiring a red gate only gets it
disabled; the rest legitimately need a served tree, a device, a token or an argument.

★ ONE I WIRED WRONGLY, AND THE MISTAKE IS THE USEFUL PART. check:settled asserts the
tree is QUIET — no builder running, no recent churn — so it fails inside a build BY
DESIGN. It passed twice standalone, I wired it on that evidence, and the next
authoritative build came back 29/30 with check:settled the only red. Running a gate
twice while IDLE proves repeatability, not compatibility with running mid-build. The
question for wave membership is not "does it pass" but "is its assertion compatible
with a build in flight". Unwired, and the reasoning is recorded in
check-no-orphan-gates so nobody re-adds it on the same evidence.

check:no-orphan-gates now reports wave membership as a second, distinct question
from reachability — "37/93 run in gates:pre or gates:post" — naming the first eight
unwired so the debt is visible rather than silent. It REPORTS rather than fails,
because most of the remaining 56 are correctly hand-run. Mutation-tested: removing
check:zone-census from gates:post moves the count 37 -> 36.

Build: 7/7 pre, 23/23 bundles, 29/29 post, exit 0.
2026-09-24 11:24:01 +00:00
doxa-maps-88655c70
a11e19a6b0 Audio Scripture in 2,121 languages: every pin used to say "not searched"
Every pin on this map has read "Audio - not searched (GRN)" for its whole life, because
Global Recordings Network sits behind Bunny Shield and answers 403 to everything, its
own robots.txt included. Re-probed this tick: still 403. But GRN was never the only
audio pool.

Faith Comes By Hearing needed no key. Their 4.dbt.io API wants one and answers 422,
which is where the earlier note stopped. Their own recordings-database page does not
use that API - it is client-rendered and pulls a STATIC public file from their CDN,
bible_recordings.json, 3,602 rows of { iso, lang_name, bible_name, bible_id, country,
video_date, audio_date, text_date }, already keyed by ISO 639-3. 2,192 codes carry a
dated audio recording; 2,121 survive verification.

Audio lifts a group off tier 0, unlike Zume, because a people group with an audio Bible
in their own language does not have "nothing" - for an oral-preference group it may be
the most useful thing on the list. It does not create tier 2, which stays reserved for
text Scripture together with film. Measured: 533 pins and 59.5M people leave the gap,
which falls 3,460 -> 2,927. Coverage 72.0% -> 76.4%. 8,094 pins now carry audio.

A 200 FROM live.bible.is PROVES NOTHING and this almost shipped wrong. It answers 200
for a fileset that does not exist, returning a fixed-size not-found shell, so the
discriminator is BODY SIZE and HEAD gives content-length for free. The shell size is
measured at run time, never hard-coded: it was 7,961 bytes on one path and 7,860 on
another within the same hour. Both fetcher and verifier abort if a bogus id ever
returns the same size as a known-good one. It caught 73 dead filesets in FCBH's own
data, all dropped.

The first full pass collected 466 HTTP 403s from bible.is - rate limiting, not dead
links; the same URL answered 200 when retried alone. Requests are now paced per host
and 403/429 retried with backoff, which cut those to 48, and a VERIFY_RETRY_UNKNOWN
pass resolved all 48. Final: 5,882 verified, 73 broken, 5 uncheckable.

Three bugs found and fixed on the way:
- The dead-link drop ran BEFORE the audio merge, so 71 dead filesets attached after the
  cleanup and shipped anyway while the pass reported "dropped 0". A cleanup that runs
  before the thing it cleans is worse than none, because its count measures an empty
  room. Moved after every source attaches.
- A VERIFY_LIMIT smoke run overwrote the real table, destroying the broken-link history
  the previous change added. A limit run is now a smoke test that writes nothing.
- The browser gate screened 3,768 of 5,889 links because its harvest never learned the
  new field.

Added VERIFY_MISSING=1 so adding one resource costs one request, not a quarter-hour
pass over 5,960 URLs.

Charts, list and map were corrected together: all three still asserted audio had never
been searched, and charts deliberately drew it as a hatched "not measured" row. That
bar is now a real count, decided by hasField() from the data itself. The checked-pools
caveat on every group now names fcbh.

Screenshot read: Uab Meto (aoz) - no Scripture, no film, no stories, not in Zume - now
shows a verified audio link and the caveat lists fcbh as checked.
2026-09-24 11:16:30 +00:00
doxa-maps-88655c70
18db274d72 The packet gate asserts RTL LAYOUT, not just the dir attribute
Some checks are pending
build-gate / build (push) Waiting to run
Arabic is one of the eleven locales, and RTL is the one layout property that can be
declared correctly and still not take effect. The gate asserted
document.documentElement.dir === 'rtl' — necessary, and not sufficient. Any inner
rule can override it while every other assertion here stays green, and an Arabic
packet with readable glyphs in LTR order reads as a styling quirk rather than the
bug it is.

It now samples up to 25 leaf text runs inside .sheet and measures the painted
extent against the element box. An RTL packet must pin its runs to the RIGHT edge,
an LTR one to the LEFT. The signal is unambiguous: en 0 right / 25 left,
ar 25 right / 0 left. Threshold is 80% of sampled runs, so one legitimately mixed
line cannot trip it.

★ MEASURE THE INLINE TEXT RUN, NOT THE BLOCK. My first probe compared each
element's box to its parent's and returned IDENTICAL numbers for Arabic and
English — it was measuring container geometry, which does not move when direction
flips, and would have "passed" an RTL packet laid out LTR. A Range over the text
node gives the painted extent, which does move. Centred text is skipped because it
proves nothing either way, and so is text that fills its box — no room to tell.

MUTATION-TESTED with the exact failure this guards: injected .sheet{direction:ltr}
so dir="rtl" REMAINS DECLARED on <html> while the layout reverts. FAIL, both
symptoms named — "only 0/25 sampled runs do (right 0, left 25) — dir=\"rtl\" is
declared and not taking effect", and "25/25 sampled elements compute
direction:ltr". All 11 locale packets pass with it live.

Verified a NEW RTL LANGUAGE end to end: Urdu added as a DATA BLOCK ONLY — 2 of 21
keys, no code change — renders dir=rtl with 25/25 runs painted right, 95 photos,
translated keys in Urdu, untranslated falling back to English, zero blank headings.
The packet's i18n contract holding for a language that did not exist when the tick
began.
2026-09-24 11:10:23 +00:00
doxa-maps-88655c70
96d7902a95 Sweep every browser gate for English-text matchers; fix the fifth and last
Some checks are pending
build-gate / build (push) Waiting to run
The same defect had surfaced in FOUR gates across previous ticks — a gate keyed on
English prose silently excludes every translated surface, or goes red on a
correctly translated one. Finding a fifth by accident would mean the pattern was
still being discovered rather than eliminated, so this sweeps all 25 gates that
drive a browser instead of waiting for the next one.

9 flagged, 8 benign: CSS class names (.vtl-fab, .stl-panel) and CSS property VALUES
(/paint|layout|strict|content/) are locale-independent by definition.

ONE REAL HIT — check-poster-prints.mjs:131, `/Gadarenes/.test(...)`. That is the
World English Bible's proper noun, so the assertion and the bug were locked
together: it passes today ONLY because point4_passage is still English in all ten
non-English blocks. The day a licensed translation lands it would have gone red on
a CORRECT packet. Now asserts the .p4-passage block by length, the same structural
fix already applied in check-packet-render and check-packet-browser.

Proved on the case the old matcher got wrong: renaming Gadarenes -> gadarenos, as
any translation would, now PASSES where /Gadarenes/ would have failed.
MUTATION-TESTED both directions — a passage truncated to 180 chars FAILS ("looks
truncated: 259 chars"), and the .p4-passage block renamed in the MARKUP FAILS
("no .p4-passage block").

★ ONE MUTATION SILENTLY DID NOT LAND, and it is worth recording. My first attempt
at the second mutation used a count=1 replace, which hit the explanatory COMMENT
rather than the markup — "p4-passage" appears twice in packet-render.js. The gate
"passed" and I nearly recorded a hole in the assertion. The assertion was sound;
the mutation was not. The sweep made the same class of error in the other
direction, flagging check-packet-render for a "Gadarenes" that lives only in a
comment explaining why it was removed.

Five instances found and fixed across five ticks; none remain in live code. The
rule for any new gate: if it matches on user-visible text, resolve the expected
string from the catalog for the page language, or assert on STRUCTURE — a class
hook, a length — instead.
2026-09-24 11:00:29 +00:00
doxa-maps-88655c70
3519ca3934 check:packet-browser asserts its headings, and stops reading them in English
This gate printed nine metrics, folded six into `const bad = a || b || ...` — so a
failure said "FAIL" without naming which — and left three unchecked: page size and
the two headings, which are the very strings its own header claims it covers ("the
gate reports which headings came back translated"). Reports, not asserts.

★ IT LOOKED LIKE IT HAD FOUND A PRODUCT BUG, AND HAD NOT. Under ?lang=es it printed
an EMPTY wheel heading; under hi it blanked BOTH — while the catalog holds "Rueda de
Oración" and "प्रार्थना चक्र". That reads exactly like a broken i18n path, and it
PASSED anyway, because nothing asserted the values.

The cause was the harness. wheelTitle was innerText.match(/^.*Wheel.*$/m) and
pointsTitle /Prayer Points|Puntos/ — neither can match Hindi, and /Wheel/ cannot
match Spanish. The product was correct in every locale all along: verified after the
fix in en/es/hi/ar/zh, RTL and Devanagari included. Had I reported that as a defect
I would have been wrong.

mark5 carried the same fault: innerText.includes('Gadarenes'), an English proper
noun that goes RED the moment point4_passage is translated. Now asserted by
.p4-passage length — the same fix already applied in check-packet-render.

Headings are now compared to the CATALOG entry for the packet's language, with
per-key fallback to English exactly as the renderer resolves it. packet-render.js
gains class="points-title"/"wheel-title" as TEST HOOKS: both were inline-styled with
no selector, which is what forced the English greps in the first place.

MUTATION-TESTED: a renderer forced to ignore the language FAILS with both headings
named and the expected values shown; a renderer emitting an empty heading FAILS
("a blank heading is worse than an English one").

★ ONE MUTATION CORRECTLY PASSED, and it marks a real limit: reverting es
wheel_title to English IN THE CATALOG passes, because rendered and expected then
legitimately agree. This gate cannot tell a translated catalog from an untranslated
one — that is check:packet-strings-parity's job, and it does assert it. Two gates,
two questions.

Partial translation re-verified through the BROWSER path: a 1-of-21-key Swahili
block renders "Mambo 12 ya Maombi" with the wheel heading falling back to "Prayer
Wheel" — translated where present, English where not, neither blank.

That makes four gates with one root cause (tabs-localized, packet-render,
deck-click, and this one twice). Any new gate matching on user-visible text should
resolve it from the catalog.
2026-09-24 10:50:27 +00:00
doxa-maps-88655c70
53ae954a7f The deck gate asserts its portraits, and can finally run in every locale
Some checks are pending
build-gate / build (push) Waiting to run
Two gaps on the same surface.

1. "116 portraits" was collected, printed, and NEVER asserted — only the QR codes
were checked against the card count, so a deck of 116 cards with ZERO portraits
passed. A prayer card without a face is a data tile, which is the whole difference
between the deck and a spreadsheet. Not hypothetical: card-render.js records that
the deck once shipped with no photos at all, because image_url was never requested
in fetchListRecords.

THE COUNT CANNOT BE PINNED — roughly half the groups have no portrait and the CDN
drops some — so two invariants instead: portraits + typographic === cards (a card
that is NEITHER means a third shape nobody checks), and a deck drawn from records
carrying image_url must have SOME, with DECK_EXPECT_PORTRAITS=0 to declare a
deliberately photo-less deck out loud.

2. The gate matched /card deck/i, so it could ONLY run in English. Pointed at
?lang=es it reported "no Card Deck button" and listed 28 buttons all reading
"Baraja de tarjetas" — the UI correctly translated, the GATE unable to see it. The
card deck is a core prayer artefact and had zero coverage in any of the ten
non-English locales, which is exactly where a rendering bug is most likely. The
label now resolves from the catalog for the page's own locale.

MUTATION-TESTED THROUGH THE REAL CAUSAL PATH, not synthetic DOM edits:
  - image_url removed from the records provider (the historical regression):
    FAIL, "116 cards and NOT ONE portrait", naming the cause.
  - the no-photo branch renamed so cards are NEITHER shape:
    FAIL, "116 cards but 0 portrait + 0 typographic = 0".
  - DECK_EXPECT_PORTRAITS=0 on that same deck: PASS, sum invariant still holding.
  - es cardDeck changed in the CATALOG without rebuilding: FAIL — so this now also
    catches catalog/bundle drift instead of passing on a stale bundle.

VERIFIED IN FOUR LOCALES, each matching its OWN label — en "Card Deck",
es "Baraja de tarjetas", ar "مجموعة البطاقات", hi "कार्ड सेट" — 116 cards, 116
portraits, 116 QR codes, @page 2.5in x 3.5in, 0 page errors in every one.

Same two patterns have now recurred three times: a count printed without an
assertion, and a gate keyed on English text that silently excludes every
translated surface. Worth checking for on any new gate.
2026-09-24 10:38:03 +00:00
doxa-maps-88655c70
2bf4001d7e Door43 spelled 32 major languages with two letters, so 2.87bn people saw none of it
The Door43 fetcher kept only 3-letter codes and dropped 2-letter ones on the grounds
that turning one into ISO 639-3 would be a guess. The caution was right and the
conclusion was wrong: ISO 639-3 publishes the correspondence itself, in the Part1
column of iso-639-3.tab. The file is now fetched rather than transcribed, so it cannot
go stale, and the run aborts if it comes back with fewer than 150 pairs.

Measured cost of the old behaviour: 32 languages spoken by 2,576 pins - Bengali,
Hindi, Spanish, Amharic, Hausa, English and more - carried Scripture and film but no
Door43 resource at all, purely over a spelling. Door43 reach goes from 169 codes and
1,051 pins to 208 codes and 3,627 pins, 3.58bn people. Table rows 226 -> 391.

ONE EXCLUSION, AND IT IS LOAD-BEARING: a tag with an '-x-' private-use subtag is
skipped on this path. Door43 publishes el-x-koine, Koine Greek, whose base 'el' maps
to 'ell' - Modern Greek, which 13 people groups on this map speak. Folding it in would
have handed those 13 groups a Koine Greek text as though it were their own language.
Region and script subtags (es-419, pt-br, sr-latn, ur-deva, zh-hant) are not private
use and are the same language, so they still fold to the base. Verified: ell is absent
from the index.

Fixed a self-erasing bug in the previous change. Dropping a dead link removed it from
the corpus, so the next verification run never saw it, so the next build put it back -
measured, both links came straight back and REAL URL returned to 8,922. The
verification table is now a RECORD, not a snapshot: it carries forward every URL a
previous run proved broken and keeps re-testing them, which is also what lets a link
the publisher later repairs come back on its own. Proven by dropping them and
re-running: "re-testing 2 URL(s) a previous run recorded broken", still broken, build
now stable across two full cycles.

Clause K's 95% coverage floor is gone, because that floor is exactly how this hid: two
missing URLs out of 3,768 is 99.9%. Every shipped URL must now appear in the record.
Poison self-test 11/11.

All 3,768 links re-verified: 3,761 live, 5 uncheckable, 2 broken and dropped.

Also fixed the chip overflow this change caused - a Door43 entry can be "Aligned Bible
(translationCore-Create-BCS) - Door43 - Portions of both Testaments", which ran off the
edge of a 330px sheet unreadable. Long chips wrap now. Screenshot read: Hindi shows
seven verified Door43 resources, all inside the panel, where it read "none found".
2026-09-24 10:27:16 +00:00
doxa-maps-88655c70
8f7a2f875d Gate the default drop: with no poster and no source, nothing prints and the reader is told
check:poster-print-fallback proves the GOOD path — no .webp, .html present, browser
rasterises. On a tree with NEITHER it SKIPs. And neither is the DEFAULT, since
posterSources and bundlePosters both default false, so the behaviour a reader is
most likely to meet was the one behaviour nothing asserted.

BUILD-POSTERS-GENERATE-ON-DEMAND-DROP-52MB claims in prose that export and print
"degrade honestly, with the preview refusing to open rather than printing an error
page". That claim was true and untested. Measured on a real dropped folder: the
wall requests the .webp (404), then the .html fallback (404), then shows "poster
not found (404)" with a Close button. 0 print frames, 0 downloads, 0 page errors.

The failure this guards is NOT a 404. It is a print dialog that OPENS on a poster
that does not exist — a blank sheet, or a low-resolution PREVIEW thumbnail printed
as though it were the 3ft poster. Both look like the feature working.

MUTATION-TESTED, two plausible wrong fixes, each caught distinctly: falling back to
printing the preview ("1 print frame(s) opened with NO poster and NO source"), and
failing silently by clearing exportState ("the reader was told nothing"). The gate
also asserts BOTH paths were attempted, so a wall that gives up before trying to
rasterise fails even while showing the right message.

★ SERVE THE TREE ROOT, NOT public/ — and this is in the gate's header because I got
it wrong first. A leaf resolves assetsBase to '../../assets', which from
<tree>/<bundle>/<profile>/ is <tree>/assets. Serving public/ puts public/assets on
that path, a directory still holding 378 posters in a dev checkout, so the poster
loads with a 200 and the gate tests the opposite of what it claims. The same click
returned 200 under `--directory public` and 404 under `--directory public/doxa-maps`.
My first reading of this was "a print frame opened on a missing poster", which would
have been a serious product defect; it was the harness.

Also audited check-packet-render this tick: all thirteen printed metrics now have
assertions behind them. No decoration left on that surface.
2026-09-24 10:16:22 +00:00
doxa-maps-88655c70
101653e3fd Verify all 3,603 links: 2 were dead, and the map showed "unchecked" on the rest
The brief's rule is to verify a URL resolves before shipping it. The table that did
that was written in August against a corpus of 426 links, while the map now ships
3,603 - and the UI matched exactly TWO of them. Two silent causes: its eBible keys
were http:// while every shipped URL now passes through httpsify, and its 306 film
keys were the old ?languageId= URLs that were later replaced wholesale by Arclight
shareUrl deep links. Links that HAD been verified displayed as "unchecked", which a
reader cannot tell apart from "nobody looked".

The old note said the film page was unverifiable by fetch, and for the URL shape it
was written against that was right: ?languageId=13562 and ?languageId=99999999 return
byte-identical HTML, so a status check there is a false green. The shareUrl migration
changed that. A path discriminates:
  /watch/jesus/german-standard      -> 200, 944,516 bytes
  /watch/jesus/pashto-yusufzai      -> 200, 810,741 bytes (distinct body)
  /watch/jesus/definitely-not-xyzzy -> 404
So 2,049 film links became checkable for the first time. The 5 remaining ?languageId=
URLs stay "unknown" with that reason - never verified, never broken.

Every host was probed with a known-good and a known-bad before being trusted, and all
answer HEAD and discriminate: ebible.org, png.bible, alkitab.pw, jesusfilm.org,
arc.gt, git.door43.org, zume.training. HEAD is deliberate - a GET over 2,049 film
pages is ~1.6GB of someone else's bandwidth to learn a status code. Five negative and
five positive controls run in band and the script aborts rather than write a table if
either arm misbehaves.

Result over 3,603 unique URLs in 365s: 3,596 verified, 5 unknown, and 2 genuinely
dead - arc.gt/092vn (Yakpa, 2 pins) and /watch/jesus/4101 (Zhuang Liujiang, 1 pin),
both re-confirmed 404 with a browser UA and a GET. The builder now drops any link
verification proved dead BEFORE tiers are computed, so a code whose only film is a 404
has no film and its tier says so. The gap filter moves 3,457 -> 3,460, which is
exactly those three pins.

Also fixed: the verification fetch hung off openPin's country branch, so a tap landing
before the country index had loaded left the whole session reading "unchecked". It is
now fetched unconditionally.

Lookup coverage goes from 2/3,605 to 3,603/3,603. New gate clause K fails below a 95%
coverage floor or if any shipped URL is recorded broken; poison self-test now 11/11.
Verified in a browser on the exact case that failed - one immediate pin tap - which
now fetches the table and renders "Scripture - link checked" and "Film - link checked";
screenshot read to confirm both chips are green where they were grey.
2026-09-24 09:58:47 +00:00
doxa-maps-88655c70
e04509a94d Pashto: 47.6M speakers were told Zume had nothing, while its Pashto site was live
Last change joined Zume by exact ISO 639-3 and left pus unjoined, which turned into a
false statement on the map: 13 people groups covering 47.6M speakers read
"Discipleship training - not in Zume's 54" while https://zume.training/pus was live
and in Pashto the whole time. Absence of a variety label is not absence of a resource.

Research for something that selects a variety found nothing that does. The publisher's
launch article scopes Pashto as Afghanistan's other official language and a major
language of northwestern Pakistan, especially Khyber Pakhtunkhwa and parts of
Balochistan - territory spanning all three of pbu, pbt and pst rather than selecting
one. The locale pages declare no language metadata at all: no hreflang alternates, no
BCP-47 tag, and og:locale is the site-wide en_US on every locale including this one.

So it ships as an APPROXIMATE match rather than an exact join or a silence: its own
field, its own dashed chip that reads "Zume, in Pashto (variety not stated)", its own
scope text in the table, never counted as an exact join and unable to move a tier. The
gap filter still cuts 12,379 features to 3,457. It is a curated three-code list, not a
runtime name match - an approximate join a reader cannot see and a gate cannot check is
the one that eventually ships a wrong link.

Why this is defensible here and would not be for film: Zume in Pashto is a written
course in one literary standard, where the pbu/pbt/pst split is largely spoken variety.
The JESUS Film is already in this index under separate per-variety dubs
(pashto-yusufzai, pashto-southern) because a dub differs by pronunciation. The medium
decides.

New gate clause J fails if any code carries both a confirmed and an approximate
training link, or if an approximate entry lacks a url, language or note - that is the
corruption that would make the qualified claim render as the confirmed one. Poison
self-test now 10/10. Both gates also harvest the new field, so its URLs are screened
rather than invisible; the browser gate screens 3,605.

Verified in a browser with three controls: deu renders gm-chip ok with a real href, kri
renders gm-chip meh with no href, pbu renders gm-chip approx linking
https://zume.training/pus and carrying "variety not stated". Screenshot read: the
dashed chip is visibly distinct from both.
2026-09-24 09:37:56 +00:00
doxa-maps-88655c70
2ed595c0f8 Zume Training: 52 languages of discipleship course, joined to 3,479 pins
The brief names zume.training every tick and an earlier pass recorded the list as
unobtainable. That was wrong. robots.txt Disallows /api/ and the homepage switcher
is client-rendered, both true, but robots.txt also DECLARES a sitemap, and the
publisher maintains /articles/zume-languages for exactly this purpose. Neither is
behind a Disallow. The real figure is 62 locales, not the ~40 estimated: 54 serve a
landing page that answers 200.

The two sources disagree in both directions - Dari and Pashto ship sites but are
missing from the article's table, and eight table rows serve no page yet - so
fetch-zume-index.mjs takes the union and then fetches every landing URL. A locale
ships a link only if its own page answered. Naming and linking stay separate facts.

Pashto is deliberately left unjoined: pus is a macrolanguage, the people-group data
carries pbu, pbt and pst, and nothing the publisher gives selects one.

Training is not Scripture and not film, so it sits in its own field and its own type
and moves NO tier. Measured: the 52 joined languages reach 3,479 pins and 4.78bn
people, and none of them are tier 0 - every Zume language already has Scripture or
film. Folding training into the tiers would have cut the gap count without anyone
having translated anything. The gap filter still cuts 12,379 features to 3,457.

Both gates had a hole this exposed. The coverage gate harvested URLs from s, f and d
only, so the 52 new URLs existed in no pin record it could see; and clause I checked
Film, Scripture and Bible Stories by host but let any other type fall through
unchecked. Both now cover training, and a poisoned row typed Scripture on
zume.training is caught. The browser gate screened 3,550 links and silently ignored
the new ones; it now screens all 3,602.

Verified in a browser with a negative control: deu opens with a chip linking
https://zume.training/de, kri opens with "not in Zume's 54" and no href, no page
errors.
2026-09-24 09:31:13 +00:00
doxa-maps-88655c70
20c1c0d325 Delete the 100-list wall's dead hidden-map export, and stop it lying to a gate
Some checks are pending
build-gate / build (push) Waiting to run
renderFor() had ZERO call sites and was the only caller of makeHiddenMap() and
loadPoster(); EXPORT_SPEC was never read. The whole chain dated from when this wall
rendered a poster by spinning up an off-screen map and compositing tiles. Export and
print now take the shipped .webp, or rasterise posters/<id>.html in the browser.
52 lines out, 17 of explanation in: 925 -> 888.

★ IT WAS NOT MERELY UNUSED — IT WAS CORRUPTING A GATE. isMapLeaf() in
generate-tree.mjs is a REGEX over FILE TEXT, so the engine handle inside the
unreachable function flagged this profile isMap=true. That flag made
verify-map-paint.mjs EXCLUDE the profile, with a written justification —
"constructs a real gl.Map ON DEMAND during poster export, off-screen at
left:-9999px" — where every clause was true of the SOURCE and false of the RUNNING
PRODUCT. A profile was excluded from a paint gate on the strength of code that never
ran, and the exclusion read as a measured product fact.

Deleting the chain flips the manifest to isMap=false, which is correct: the poster
wall is a wall of cards, not a map. The gate's comment now says it is absent because
it IS NOT A MAP, rather than because it is a map the gate cannot see.

⚠ The explanation had to be written carefully. Naming the engine handle whole in a
comment RE-TRIPS the same regex and reinstates the bug, so it is spelled in two
pieces on purpose with a note saying why — the same class as asserting Mark 5:1-20
via the English word "Gadarenes": describing a thing in the terms the detector scans
for makes the description itself the defect.

ALSO FIXED, found while verifying: check:poster-print-fallback probed
'../assets/posters/<id>.html', one level up. A leaf lives at
<tree>/<bundle>/<profile>/index.html and resolves assetsBase to '../../assets', two
levels up — so on a tree that DID carry the sources the probe 404'd and the gate
SKIPped with "posterSources:true was not set" when it had been. A precondition check
that is wrong about the precondition silently turns the whole gate off.

VERIFIED AT THE RENDERED LAYER after the deletion, not before: the print path still
rasterises with every .webp aborted ("generating the poster", clean terminal state),
and the card deck still prints 116 cards at 2.5in x 3.5in with 116 QR codes and 0
page errors.
2026-09-24 09:12:17 +00:00
doxa-maps-88655c70
6732c8d430 Guard the packet catalog: the 12 prayer points and the wheel had no parity gate
check:i18n-parity covers library/i18n/locales — the APP catalogs. Nothing covered
library/scripts/posters/packet-strings.json, which is a separate file with its own
shape and is where the 12 PRAYER POINTS and the PRAYER WHEEL live. SIX gates read
it and not one checked that a language block is complete.

All ten locales happened to be healthy — 19/21 string keys, 12 points with every
field, 12 rhythms — but nothing held them there. A point dropped from one block
renders that slot through the per-key English fallback: correct runtime behaviour,
invisible defect, on a page the reader is meant to pray through.

★ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that is the load-bearing choice.
check-packet-render printed "19 strings translated" and only ever asserted `!== 0`,
so a locale regressing from 19 to 1 passed silently. The fix is NOT to pin 19:
measured, de translates 18 of the same 19 keys and fr 17, because a word or two
legitimately matches English — a hardcoded count fails two CORRECT catalogs. So
parity asserts every English key EXISTS in every block, a structural fact no
translation choice can change, and the render gate gains a FLOOR of two-thirds
derived from the catalog itself.

point4_passage + point4_passage_credit are DECLARED-ABSENT with the reason on
record (Mark 5:1-20, public-domain licensing per language). Naming the exception
beats tolerating it silently: the day a licensed translation lands, deleting the
entry tightens the gate by itself.

MUTATION-TESTED, five defect classes, each caught with a precise message: a dropped
string key, an 11th-of-12 prayer point, an EMPTY point text, a missing wheel rhythm,
and a block reverted wholesale to English. The floor was tested separately by
reverting de to 3 translated of 19 — "only 3 differ from English (floor 13)".

★ THE TWO GATES ANSWER DIFFERENT QUESTIONS, and a partial language shows both at
once. Swahili at 3 of 21 keys, no points, no rhythms: check-packet-render PASSES —
a partial translation renders, Swahili where translated, English where not, zero
blank slots — while check:packet-strings-parity FAILS and names exactly what is
missing, including that the points and wheel would fall back wholesale. Before this,
only the first question had a gate, so a half-finished block was indistinguishable
from a complete one at build time.

gates:post is now 22 steps; 11/11 packets still pass the sweep.
2026-09-24 08:57:34 +00:00
doxa-maps-88655c70
e0f2d522bb All eleven locales complete — the last 162 keys of i18n debt, and the ratchet empties
Some checks are pending
build-gate / build (push) Waiting to run
regions.json for it/ro/zh, 54 keys each. check:i18n-parity now reports "0 key(s) of
known translation debt remain", closing the 429-key gap it found when it was written.

★ THE DEBT MAP IS EMPTY RATHER THAN FULL OF ZEROS, and that is the whole point of
this ratchet: a locale with NO entry must be complete — the gate fails it outright
for a single missing key — so an empty DEBT is the strongest state it can be in, not
an unset one. Writing { it: 0, ro: 0, zh: 0 } would mean the same today and quietly
permit a regression the moment someone "adjusted" a number. Mutation-tested both
ways: removing ONE key fails ("it is missing 1/176 key(s) and has no DEBT entry"),
deleting the whole file fails (54/176).

THE CARD'S ESTIMATE WAS WRONG AND IS CORRECTED. It recorded "54 keys of full
SENTENCES". Measured: 45 short labels (median 14.5 chars) + 9 longer strings, three
carrying %count%/%countries%/%region%/%country% interpolation tokens. The catalog is
keyed by its own English TEXT, not symbolic keys — which is exactly why the gap was
invisible: the fallback renders the English key itself, so key and value are
identical in English and nothing ever looks broken.

PLACEHOLDERS HELD BY A PROGRAMMATIC CHECK, not by care: every %token% in every
English value must appear, same set, in all three translations. 0 mismatches, 0
empty values. Mangling one breaks interpolation at runtime with nothing to catch it.

VERIFIED AT THE RENDERED LAYER on the regions-list leaf, en/it/ro/zh: labels
translated (Regions -> Regioni), the intro sentence translated, and the INTERPOLATED
count sentence correct — "315 people groups in 41 countries" -> "315 popoli in 41
paesi", both placeholders substituted with real numbers. Zero page errors. Each
locale asserted to DIFFER from English, so a silent fallback could not pass.
★ That sentence is the exact bug regions-list.vue:144 records: the Russian string was
"ALREADY in library/i18n/locales/ru/regions.json — copied, never wired", caught by
reading a screenshot rather than by any assertion. Now it is asserted.

zh is SIMPLIFIED, measured not assumed: the existing zh catalogs carry 33
Simplified-only characters against 4 Traditional. Vocabulary follows each locale's
own established terms — Popolo / Popor / 民族, Regione / Regiune / 区域 — and WAGF
stays an acronym, as fr and de already had it.
2026-09-24 08:48:02 +00:00
doxa-maps-88655c70
95b5586cc5 Assert the packet photos the spec names — "20 per page WITH IMAGES"
Some checks are pending
build-gate / build (push) Waiting to run
check-packet-render printed "95 cards (0 photos, 95 typographic)" and PASSED, in
every locale. The packet spec names the photos explicitly, so that was the last
unasserted number on this surface.

The cause was my own fixtures, not the product: I regenerated the packets with
NOPHOTOS=1 two ticks ago for speed. Regenerating properly gives 95 photos, 3.5MB,
0 broken. But a gate that passes a packet violating a core spec line is the defect
whoever made the artefact, and this one would not have noticed a real photo-fetch
failure either.

★ THE COUNT CANNOT BE PINNED, so this is two invariants rather than "=== 95".
Roughly half the groups genuinely have no photo, the CDN drops some — de came back
94/95 on this very run, which is exactly why a hard count would have been wrong —
and NOPHOTOS=1 is a SUPPORTED dev mode for fast layout iteration. So:
  1. photos + typographic === cards. Every card is exactly one shape; a card that
     is neither means the renderer emitted a third nobody is checking.
  2. a photo-enabled packet must have SOME photos. Zero is acceptable only when the
     run declares itself photo-less via PACKET_EXPECT_PHOTOS=0 — "deliberately
     none" said out loud, rather than indistinguishable from "silently none".

MUTATION-TESTED BOTH. Removing a single .card img yields "95 cards but 94 photo +
0 typographic = 94". The photo-less packet FAILS by default and PASSES under the
opt-out. (My first attempt at the sum mutation silently did nothing — the card
blocks are 11,340 chars and I edited a 1,200-char window — so the assertion only
counts as tested because the second attempt targeted the real .card img.)

All 11 locales regenerated WITH photos and re-swept: 11/11 pass. i18n re-verified
end to end — a partial Swahili block, 3 of 23 keys, added as DATA ONLY with no code
change: translated keys render in Swahili, untranslated fall back per key to
English, zero blank slots.
2026-09-24 08:35:19 +00:00
doxa-maps-88655c70
e5fe33b230 prayer-walk-planner can be shared — the last published map with no sharing path
check:share-policy's known-debt line is now GONE: it named two published maps, then
one, now zero. Every published map has exactly one decided sharing path.

★ THE OPEN QUESTION WAS ANSWERED BY THE CODE, NOT BY A RULING. This was held back
asking "is it MEANT to be shareable? a link carrying no route arrives as an empty
planner, and a button that reliably produces a blank map is worse than none". That
framing assumed a design choice existed. It did not: the profile keeps polygons in
localStorage and has NO BACKEND — its own header says so — so a URL cannot carry a
drawn route under any design. The shareable artefact is the PLANNER, exactly as
every other map shares its own page. The failure only exists if the link CLAIMS to
carry the route, so the status strip says once, on click: "This link shares the
planner. Your drawn zones stay on this device." — using the status mechanism the
profile already had rather than adding a surface.

★ ADDING <ShareButton> WAS NOT ENOUGH, AND check:share-policy COULD NOT SEE IT.
The button rendered NOWHERE. This bundle mounts its SFC directly as the custom
element — deliberate, it is what surfaces the defineExpose()d client-side API on the
DOM element ("the only API we can build has to be CLIENT SIDE") — and so installed
no i18n. ShareButton calls useI18n(), which throws in setup(), and index.js's own
header records the cost: "a thrown setup() removes the whole shadow-DOM subtree
silently." Measured: a runtime SyntaxError, five toolbar buttons rendered, the sixth
absent — while the policy gate, which counts <ShareButton> in SOURCE, said 1 button
and PASSED. That blind spot is now written into the gate, pointing at the smoke that
does catch it.

Fixed WITHOUT changing the mounting shape: defineCustomElement takes configureApp
whichever component is the root, so i18n installs and the API stays on the element.
The direct-mount decision and i18n were orthogonal; I had assumed they were coupled.

VERIFIED AT THE RENDERED LAYER: share-roundtrip-smoke resolves the url 200 text/html
and REOPENS it as <prayer-walk-planner> with a rendered canvas. The client-side API
was re-verified after the change — all 7 exposed methods present, addPolygon ->
listPolygons round-trips, 0 page errors. i18n verified in 6 locales: Share /
Compartir / مشاركة / साझा करें / 分享 / Partager. Added to smoke:share-all, now
12/12 pass 0 fail.
2026-09-24 08:23:30 +00:00
doxa-maps-88655c70
3a468c2bc3 The poster print preview rasterises too, and a deployment can ship sources without rasters
Some checks are pending
build-gate / build (push) Waiting to run
posters/*.webp is not shipped by default (42MB; bundling pushed the tree to 80MB,
git-packed to 57MiB, and the catalog rejected the push). The DOWNLOAD path has
handled that since d154a76 — PosterWall fetches posters/<id>.html and rasterises
it in the browser. The PRINT path did not: it handed an <img> a url it never
checked, so on a default drop the SAME poster downloaded fine and PRINTED as
"Poster not available yet." That is the bad first impression
IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF called out by name.

Three things were missing; all three are here:

1. printPoster now climbs the same ladder as the download path — probe the webp,
   and only on a miss fetch the .html, rasterise, and verify PIXELS before opening
   the dialog (an SVG in an <img> is an isolated document: a lost stylesheet prints
   a black square with no error). Blob urls are held until unmount, not revoked at
   print time — the preview <img> is still reading from them.

2. publish-html.mjs wrote into public/assets/posters, which is a GENERATED MIRROR
   that every `bun run tree` rmSync's. Its output never survived, which is a large
   part of why the fallback still had no input months after it shipped. Retargeted
   at data/posters, the tracked source the mirror is made from.

3. bundlePosters is one boolean over a directory holding 40.9MB of rasters AND the
   28 sources, so it could only ever ship both or neither — and "neither" is what
   made both full-size paths 404. posterSources:true ships the sources without the
   rasters, the combination that was previously inexpressible. DEFAULT UNCHANGED:
   28 sources are 27.9MB raw (6.9MB packed, measured with pack-objects), real
   weight in a published tree that needs a deployment decision, not a silent one.

VERIFIED AT THE RENDERED LAYER, not by reading source. check:poster-print-fallback
clicks the real control with every posters/*.webp ABORTED at the network layer —
the default-drop condition forced rather than hoped for — and asserts the HUD
enters "generating the poster" and settles clean. Mutation-tested: removing the
fallback produces 4 failures including "no posters/*.html was fetched". The other
branch is verified too — with a webp present it is used directly, zero html
fetches, no rasterise — so the extra probe is paid only on a miss.

★ THE GATE WAS WRONG THREE TIMES FIRST, each time blaming the product for a
harness fault, which is why it now asserts the HUD instead of an internal:
  - it matched /print/i, but the control reads "UPG prayer poster" — the product
    names the ARTEFACT, not the action;
  - it patched Document.prototype in the PARENT realm while openPrintFrame writes
    into the iframe's own;
  - openPrintFrame calls doc.open() AFTER append, and open() RESETS the document,
    discarding a write patched at append time.
Through all three the .webp/.html request counters looked corroborating, because
the wall loads both by itself. The HUD is what a user sees, survives a refactor of
the print internals, and cannot silently miss.

The 28 poster sources are committed because the fallback has no input without
them. city-* posters are deliberately NOT covered: they fetch OSM street tiles at
generation time, so they are not reproducible offline and their rasters must stay.
2026-09-24 08:07:22 +00:00
doxa-maps-88655c70
ff8a318e51 Let the map gate start its own server, so it will actually run
Registering a gate is not enough to make it run. verify:browser-all is invoked NOWHERE in
this repo; build:unlocked runs only `bun run verify`, and verify-all.mjs skips anything
marked browser:true unless --full is passed. That is precisely how two gates rotted here
before — "both had rotted unnoticed precisely because the build chain has never run them".
A gate whose precondition is "remember to start a server first" has a precondition nobody
remembers.

So the precondition is gone. If nothing answers, the gate spawns serve-strict on an
ephemeral port — ephemeral so two gates running at once cannot collide on 4173 — and shuts
it down afterwards. `bun run verify:gospel-map` now works from a clean shell with no setup.

An explicitly named BASE_URL or argv is honoured and never auto-served: a caller naming a
host means it, and quietly pointing at a different tree would be worse than skipping.
Verified — a dead explicit URL SKIPs and starts nothing.

★ THE FAILURE PATH LEAKED A PROCESS AND THE PASS PATH DID NOT. process.exit(1) inside the
try terminates before the finally block's cleanup runs, so a failing run left its spawned
server alive: measured 0 strays after a pass, 1 after a fail. A gate that leaks only when
it fails leaks exactly when someone is re-running it to see what broke. The exit code is
now set, not thrown, and the process exits after cleanup. Re-measured from a clean
baseline: 0 strays after pass, after fail, and after restore.

One scare worth recording: a stray server DID show up mid-test and it was the orphan from
the pre-fix run, six minutes older than the code being tested. Checking its start time
rather than its existence is what told them apart.
2026-09-24 07:44:26 +00:00
doxa-maps-88655c70
3d3586b5f9 Commit the map check, so the browser path survives this flight
Every browser check written for this bundle lived in _qa-cycle/, which is gitignored: a
dozen probes proving the pins render, the sheet opens with real links and the deep link
filters the table — all of which disappear with the working tree. check:coverage-consistent
guards the DATA and cannot see the map at all. The repo has carded orphaned gates twice;
this is the same loss one step earlier, a gate that was never committed.

It asserts, against the SHIPPED tree through the production-parity server: the element
mounts and exposes its state handle (a stale-SRI leaf silently refuses to execute, which
looks identical to a mount failure); every pin in the source is RENDERED, not merely
loaded; a tap opens a group with a NAMED language; the table deep-link carries that
group's own ISO code; and the gap filter leaves only tier 0.

★ IT TOOK THREE TRIES TO MAKE IT WORTH TRUSTING, and each failure is the point.

FIRST it was FLAKY — 1 run in 3. Clause 4 waited a fixed 900ms after clicking the filter,
but setFilter is applied asynchronously, so it queried mid-transition and still saw tier 1
and 2. It now waits for the map's own idle event. 5 of 5 runs clean. A gate that fails a
third of the time on correct code is a gate that gets switched off.

SECOND its doctrine check had a SAMPLE SIZE OF 1. It validated the two URLs of the one
group it happened to open — 1 of 2,685 codes — so a jw.org link planted anywhere else
passed cleanly. MEASURED: the injection did not fail the gate until the scan covered the
whole index. It now checks all 3,550 links, and fails if the index carries none at all,
because a scan over nothing passes vacuously.

THIRD, adversarially tested end to end: with jw.org injected on code `tel` the gate exits
1 naming the publisher; restored, it exits 0; the file's md5 is unchanged either side.

Registered in package.json and in verify-browser-all's GATES array — check:no-orphan-gates
confirms it, and check:quoted-scripts passes. Without a server it SKIPS loudly with the
command to start one, rather than passing quietly.
2026-09-24 07:31:01 +00:00
doxa-maps-88655c70
e5c2c77561 all-people-groups can be shared: its own button, verified by a round-trip
Some checks are pending
build-gate / build (push) Waiting to run
It was published, embeddable and reachable with NO sharing path by any mechanism —
no ShareButton, no shareUrl config, no composite parent overriding one on its
behalf — while every other published map had exactly one. A reader who landed on
it could not send it to anyone, which is the point of an embeddable jamstack map.
check:share-policy's known debt falls from 2 published maps to 1.

The button is in a MapToolbar (pure layout shell: position, z-index, mobile
offsets) rather than hand-positioned, so it sits where every other map's controls
sit. has-geocoder=false because this profile mounts no search pill. Only the share
control is added — zoom/location/fullscreen each need a live mapRef this bundle
does not expose, and the card asks for a sharing path, not a toolbar.

NOT gated on appReady, unlike this bundle's legend. Sharing a map does not require
its data to have loaded, and gating it would remove the affordance exactly when a
slow or failed load makes a reader most likely to want the link.

★ WHY A RENDERED CHECK WAS NEEDED. check:share-policy counts <ShareButton> in
SOURCE. Mutation-tested by gating the toolbar off: the policy gate still read
"1 button / own-button" and stayed GREEN while the button was absent from the DOM.
A source count cannot tell a rendered button from an unrendered one.

So this is verified by the EXISTING share-roundtrip-smoke, not a new gate. I wrote
one, then deleted it on finding that smoke does strictly more — it fetches the URL
and REOPENS it, asserting the same custom element renders, which catches SPA
fallbacks and hash-stripping that a link-text check cannot. A second share checker
would be the fourth-print-path mistake this repo has already paid for once.
Result: share URL resolves 200 text/html and reopens as <all-people-groups> with a
rendered canvas.

i18n verified at the rendered layer: under ?lang= the button title reads Share /
Compartir / مشاركة / साझा करें / 分享, so the new affordance is translated in every
locale, RTL included. buttons.share is one of the keys restored to parity earlier.

ALSO FIXED, found by running the suite: smoke:share-all was testing the LEAF urls
of the two 'header'-policy bundles, whose button lives in the MFE shell by design,
so it reported 10/12 with two failures that were the policy working as intended.
Swapped country-posters/countries-map for country-posters-mfe and dropped the stale
my-upg-100-list leaf (upg-100-mfe was already listed). Now 11/11 pass, 0 fail.
check-share-policy records the coupling so the list cannot drift from the policy
again.

prayer-walk-planner stays debt-no-share, deliberately. It builds a personal walking
route from polygons the reader draws; a link carrying none of that arrives as an
empty planner, and a button that reliably produces a blank map is worse than none
because it looks like sharing works. That is a semantics decision, not an
implementation one.
2026-09-24 07:25:19 +00:00
doxa-maps-88655c70
02351725ba Gate the shape of last tick's bug, not just the instance
70 story sets were typed as Scripture for seven ticks because NOTHING compared a row's
label to where it points. Clause H, added last tick, catches that exact mislabel. It would
not have caught the same mistake made anywhere else.

Clause I asserts the general shape: Film only on jesusfilm.org or arc.gt, Bible Stories
only on git.door43.org and only under that name, Scripture never on a film host. Audited
across all 3,550 rows first — 0 violations, plus 0 duplicate (language, url) pairs — so
this ships as a guard rather than a fix. The 8 languages carrying more than one Scripture
URL were checked and are correct: Cebuano really does have an eBible ULB alongside Door43's
ULB and UDB, which is three translations, not three copies.

THE PASS LINE NOW NAMES EVERY CLAUSE. It listed four of nine, which reads as a narrower
check than it is, and the next reader calibrates their trust on that line rather than on
the source. Nine clauses, nine names, and --poison still fails 9/9 on demand.

The table also states its scope breakdown where a reader meets it — 855 New Testament, 254
Portions of both Testaments, 136 Full Bible, 14 Old Testament, 13 Portions — with the
reason it is counted from each publication's own book list rather than its subject label.
"How much Scripture exists" is the question under every other number in that table and it
was previously visible only by sorting a column.

Verified: gate passes, self-test 9/9, the table renders its new Scope column, and pins,
scope and deep-link probes all pass.
2026-09-24 07:06:21 +00:00
doxa-maps-88655c70
25405d571a Scope the Door43 Scripture, and stop 70 story sets being typed as Scripture
Door43 entries carry their own book list. Each ingredient is categorised bible-ot or
bible-nt, so a Door43 Scripture entry can say how much Scripture it is, exactly as an
eBible one does — and it is already in the response we fetch, so this costs no extra
requests. 35 entries scoped: 15 New Testament, 13 Portions, 4 Full Bible, 3 Portions of
both Testaments.

THE SUBJECT LABEL IS NOT THE SCOPE, which is why it is counted from the data. An entry
filed under "Bible" can be 27 NT books and nothing else. Calling that a Bible because of
its folder name is the same error as calling Open Bible Stories Scripture.

★ AND THAT ERROR WAS ALREADY SHIPPING, 70 ROWS OF IT, BY MY OWN HAND. Tick 15 added owner
disambiguation so two same-named entries could be told apart — "Open Bible Stories
(OBS-TLF)". The type test was `d.s === 'Open Bible Stories'`, an exact match, so every
disambiguated label fell through to Scripture. 70 story sets have been typed as Scripture
for seven ticks: Bhojpuri, Maithili and 68 more told a reader they had a translation when
they have 50 illustrated narratives. Found only because the type counts moved when this
tick changed the test to a prefix.

An exact match against a label that can grow a suffix is the whole defect, and the fix is
not enough on its own — so clause H now asserts that nothing named "Open Bible Stories" is
typed as Scripture, poisoned with the rest. 8/8 clauses fail on demand.

The Door43 chip in the sheet had the same exact-match and now reads "Bible Stories
(OBS-TLF) · Door43 · <scope>". The one `===` left in the fetcher is correct and was
checked rather than blanket-replaced: it compares the RAW subject from the API, which
never carries an owner suffix.

Every Scripture row on the map now has a scope. The 2,278 blanks reconcile exactly to
2,087 film plus 191 Bible Stories — none of which is a portion of the canon.

Verified: gate passes, its self-test fails 8/8 on demand, and the Door43, pins, table,
scope and deep-link probes all pass.
2026-09-24 06:58:12 +00:00
doxa-maps-88655c70
e952e01e4c Assert the packet numbers that were only being printed, and run the gate on every locale
check-packet-render's own header states the law it exists for — "never print a number
without asserting on it" — and two of the nine numbers it printed were decoration.

PAGE SIZE. Printed "612 x 792 pt", never compared. The landscape check only catches
h <= w, so a wrong PORTRAIT size sailed through: A4 is 595x842, taller-than-wide like
Letter, and would have passed on a packet printing with wrong margins on every US
printer. Not hypothetical — @page{size:...} silently falls back in Chromium, which this
repo has already paid for once. Now asserts every page's MediaBox against Letter.

CARD COUNT. `if (!r.cards)` passed a packet carrying ONE card. Counts must stay relative
(a per-zone packet has its own group count), but relative does not mean unasserted:
sheets === 1 + ceil(cards/PER_PAGE) + 2 holds for any zone.

★ That was not enough, and the mutation test is what showed it. Deleting a whole list
sheet removed 15 cards AND one sheet, so the invariant still held — 80 cards / 7 sheets
is valid arithmetic — and the packet PASSED while missing 15 people groups. Internal
consistency is not completeness. So it now cross-checks an INDEPENDENT witness: the
{from}-{to} of {count} header the renderer prints on each list page, where count comes
from groups.length and does not move when a page is lost. It also requires the ranges to
TILE [1..count], which catches a missing MIDDLE page that the totals alone cannot see.

Those headers are TRANSLATED and the languages disagree on order — en/ar read "1-20 of
95" while hi reads "95 में से 1–20" and zh "95 之中的 1–20", total FIRST. A positional
regex would read those backwards. Parsed as digits with max() as the total instead, and
locale digit forms normalised to ASCII, so it is grammar-independent. Verified by
running the missing-middle-page mutation in Hindi as well as English.

MARK 5 WAS ASSERTED BY AN ENGLISH WORD. body.includes('Gadarenes') would go RED on a
correctly-translated packet, and passes today only because every locale falls back to
English for that key — the check and the bug were locked together. The renderer now
emits class="p4-passage"/"p4-credit" as test hooks (the block is styled inline and had
no selector at all), and the gate asserts length >= 1200, which holds in any language.

check:packets-all runs the gate over EVERY generated packet rather than one hand-named
file. The single-file gate SKIPs without an argument, so it was in package.json, passing
in all 11 locales, and in NEITHER gates:pre NOR gates:post — it ran only when someone
typed a filename. Now in gates:post (21/21, 11 packets through a real browser). SKIPs
when no packets are generated, so a clean checkout is unaffected.

Mutation-tested: A4 FAILs, a dropped sheet FAILs, a missing middle page FAILs in en and
hi, a truncated passage FAILs, a removed passage FAILs. i18n verified end to end by
adding a PARTIAL new language as a data block only — 3 of 23 keys — and confirming the
translated strings render, untranslated keys fall back to English, and nothing is blank.
2026-09-24 06:55:58 +00:00
doxa-maps-88655c70
2484f26304 facet gate: say "I cannot measure this engine", not "the map is broken"
Some checks are pending
build-gate / build (push) Waiting to run
Run against ?provider=maplibre the gate reported

    FAIL — the map never populated (no rd-bubbles features after 90s)

about a dashboard that visibly renders all 2107 dots with a working
choropleth. It reads the source's live GeoJSON off `_data`, a private Mapbox
internal. MEASURED on one dashboard, one build, only the engine differing,
by calling the app's own setData() to shrink 2107 features to 5:

    mapbox    _data 2107 -> 5      _options.data 2107 -> 2107   (_data is LIVE)
    maplibre  _data never an FC    _options.data 2107 -> 2107   (NEITHER is live)

So on MapLibre no private field tracks setData and the wait can never
succeed. It now diagnoses the engine and SKIPs with the reason. Same
false-FAIL shape removed from check:filter-width last tick: a control that
accuses correct code is worse than no control.

★ DELIBERATELY THE SMALL FIX. Re-expressing the assertion on the public
querySourceFeatures() would change the gate's reported counts, and the two
earlier attempts at that made things worse -- one failed BOTH engines by
preferring the construction-time _options.data, the other called a
node-side helper from inside a page-evaluated string where it is simply
undefined. Every line of page code added here is self-contained for exactly
that reason.

THREE CONTROLS:
  maplibre     -> SKIP, exit 0, naming why it cannot count
  mapbox       -> PASS, exit 0
  --self-test  -> gate REJECTED the reverted build, catching
                  'circle-color is the flat literal "#ef6c00"'
The third is the one that matters: the gate still catches the bug it exists
for rather than having been quietened.

This removes the LIE, not the GAP. MapLibre dashboards remain unverified by
this gate; BUG-FACET-GATE-CANNOT-MEASURE-MAPLIBRE stays open, downgraded to
P3 and re-scoped to the coverage question.
2026-09-24 06:41:38 +00:00
doxa-maps-88655c70
f03b900ccc filter-width: an absent server is not a width regression
The gate printed "FAIL — 2 host(s)" when it had measured NOTHING —
ERR_CONNECTION_REFUSED on :4173, "UNMEASURED" twice, then a failure. That
accuses the product of a regression on the strength of a missing process,
and it already cost one wrong report in this flight: the gate was reported
as failing when it had simply never run.

★ THIS DOES NOT REVERSE THE AUTHOR'S RULE. "An unmeasured host is a failure.
A gate that quietly drops a host reports success about something it never
looked at" is correct for a STRUCTURAL miss — the page loads and the trigger
or tab cannot be found, meaning the gate has been defeated — and that path
is kept, comment and all. Only connection-level errors are separated out.

It is also not a judgement call, because the house already decided: four
sibling browser gates skip on an unreachable host and say so in the same
words — check-fits-the-screen, interop-harness, check-catalog-identity and
check-legend-matches-source all print "SKIP — ... unreachable right now.
Not a product finding." This gate was the outlier.

THREE CONTROLS, ALL FIRED:
  no server                      -> SKIP, exit 0
  server + healthy code          -> PASS, exit 0
  real regression, the original
  flex RANGE put back on
  .filter-dropdown               -> FAIL, exit 1 ("the row grew 369px")
The third is the one that matters: it proves the gate can still catch the
bug it exists for, rather than having been quietened.

Before believing any of those browser measurements I ran `bun run tree` and
confirmed check:sri 0 — a single-bundle build leaves stale SRI, the browser
refuses the script, and the page then measures clean for the wrong reason.
That trap made a negative control vacuous earlier in this flight.
2026-09-24 06:34:29 +00:00
doxa-maps-88655c70
e8e2be1498 Merge main into bundler-2/work
Some checks are pending
build-gate / build (push) Waiting to run
# Conflicts:
#	package.json
#	public/doxa-maps/all-people-groups/all-people-groups.js
#	public/doxa-maps/all-people-groups/all-people-groups/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
2026-09-24 06:25:29 +00:00
doxa-maps-88655c70
9056b9329c All 12,379 pins were missing on MapLibre, and now a gate says so
Having found that MapLibre drops a whole layer for one unrecognised paint
property, I swept all 17 isMap leaves under ?provider=maplibre listening to
the map's own error event -- the only place this class surfaces. One more
real instance:

    layers.apg-pins.paint.circle-sort-key: unknown property

circle-sort-key is a LAYOUT property, written into paint. Mapbox tolerated
the misplacement; MapLibre rejected the layer, so every pin on
all-people-groups was absent under the keyless engine while the page looked
perfectly healthy. Moved to `layout`; the sort order itself is unchanged.
Screenshots read on both engines: 12,379 pins, identical.

NEW GATE check:maplibre-drops loads every isMap leaf under maplibre and
fails on the layer/source-dropping error family. It skips cleanly when the
server is unreachable, because an UNMEASURED check is not a FAILED one --
the flaw already filed against check:filter-width.

★ THE NEGATIVE CONTROL WAS VACUOUS TWICE BEFORE IT WAS REAL, and that is
worth more than the fix. The first poison renamed the layout block, which
does not reproduce the bug -- the property simply vanishes, no error. The
second poison was faithful, circle-sort-key back in paint, and the gate
STILL printed PASS. Cause: I rebuilt with `BUNDLE=... vite build` and did
not run `bun run tree`, so the leaf carried stale SRI, the browser REFUSED
the script, no map mounted, and the gate saw zero errors. A check that ran
correctly and measured nothing, reading as green -- the first entry in
AGENT-BRIEF's troubleshooting table, walked into anyway.

After `bun run tree` (check:sri 0) the gate FAILED on the real bug, naming
the exact property, and PASSED again once restored. Both directions seen on
this codebase, which is the only thing that makes its green mean anything.

gates:pre 0, gates:post 17/18 (pre-existing verify/tables-complete),
check:doc-scripts 0, check:no-orphan-gates 0.
2026-09-24 06:24:40 +00:00
doxa-maps-88655c70
4cb04480cc Localize tab labels for it/ro/zh, and prove the catalog is actually read
Some checks are pending
build-gate / build (push) Waiting to run
120 keys across three locales: legend.json (12 each), search.json kinds (10
each) and tabs.json (18 each, previously absent entirely). Each locale follows
its own established vocabulary rather than a parallel one — Popolo/Popor/民族,
matching the terms already used in that catalog's detail and legend strings.
zh stays Simplified here, as the rest of this tree is, even though the prayer
packet's zh block is Traditional: a reader sees 民族 next to 祷告进度, never
the two files side by side.

Ceilings drop 94 -> 54 per locale. regions.json is the only i18n debt left in
the app: 54 keys of full sentences, three locales.

WHY THE NEW GATE. check:i18n-parity compares catalogs TO EACH OTHER, so it
certifies a file no t() call ever reaches — perfect symmetry, zero consumers,
green forever. Grepping for t('tabs. found ZERO call sites and tabs.json was
nearly recorded as dead. It is not: the only consumer builds the key
dynamically (SemanticTreeLegend.vue:40, 'tabs.' + (tab.i18nKey || tab.id)),
which no literal-prefix grep can see. The reverse error appeared in the same
pass — regions. and options. matched only poster-regions.mjs and a JS options
parameter. Static reachability is unavailable in both directions, so
check:tabs-localized renders the labels and reads them.

It asserts DIFFERENCE from English, not mere presence: te() falls back to the
hardcoded English label, so "shows some text" passes on the exact failure
being tested. Mutation-tested three ways — missing catalog FAILs, an all-English
catalog FAILs (first draft PASSed it, taking a per-pair exemption seven times),
one legitimate collision still passes with a note.

Both i18n gates now run in gates:post; neither was wired into the build before.

Also fixed, found by the build rather than sought:
- check-i18n-parity hardcoded an absolute path, so the studio copy silently
  graded wt-packet's locales and reported PASS on a tree it never read. Now
  repo-relative. That exposed the studio's own locale tree as a strict subset,
  103 keys behind including buttons.json:share — the very orphan this gate's
  header says it was written to catch, reappeared unseen in a second repo.
  Synced forward: zero studio-only keys, zero text conflicts, lossless.
- index.json still declared field-access-visas with rows:1146, a leftover from
  the commit that removed that table as private. The generator already filters
  private tables; the committed artifact predated the filter. Removing the
  declaration also removes the row-count leak. Copying the file in would have
  reopened the leak.
- check:sfc-parse needs an installed node_modules and is the first pre-gate to
  fail without one, so a dependency-less worktree reads as a parse error.
  Documented, with the reason switching it to bun only appears to fix it.
2026-09-24 06:23:30 +00:00
doxa-maps-88655c70
7b27bf1b83 Every link https, and a gate so it stays that way
1,237 of the URLs we ship were plain http — all of eBible.org, png.bible and alkitab.pw,
with the host spelled `eBible.org` on six of them. Every one of those hosts serves https
directly (sampled 12/12 at 200) and redirects http there anyway, so the plain link bought
nothing but a redirect hop and a downgraded scheme in a field worker's browser, on a page
that may itself be embedded over https.

NORMALISED WHERE A URL ENTERS THE INDEX, not per fetcher. Doing it in the eBible fetcher
first left 120 links behind — the curated scripture_url values, which fill-never-overwrite
preserves and which therefore never pass through a fetcher at all. One httpsify at every
emit point takes it to 3,548 https and 0 http.

Scheme and HOST only. The path is left exactly as published, because a path is
case-sensitive and "tidying" it would break the link rather than improve it.

★ THE GATE CAUGHT MY OWN DRIFT, ten seconds after I added the clause. Clause G failed —
but as clause E, "1237 table URL(s) exist in no pin record": I had rebuilt the pins and
not the table, so the two surfaces disagreed about every eBible link in the corpus. That
is precisely the class this gate was written for last tick, and it found it on its first
real use rather than three ticks later by hand.

Clause G is poisoned in the self-test alongside the rest: 7/7 clauses now fail on demand.

Verified: 0 plain-http URLs remain, six upgraded links sampled live all answer 200 over
https, the coverage gate passes, and pins, table, deep-link, tooltip and list probes all
still pass.
2026-09-24 06:15:58 +00:00
doxa-maps-88655c70
0fffb90cca test(i18n): ratchet down again — seven locales must now stay complete
Some checks are pending
build-gate / build (push) Waiting to run
344 -> 282 keys. de and hi reached zero and join ar/es/fr/pt/ru as entries
DELETED from the DEBT map: the gate treats an absent locale as "must be
complete", so removal is what makes a finished translation permanent.

The three that remain (it, ro, zh) are each missing WHOLE catalog files rather
than stray keys, which is worth stating in the file — it tells the next person
these are real translation work, not the propagation gaps the last two ticks
closed.

Confirmed the guarantee before relying on it: deleting one Hindi tab key reports
"hi is missing 1/176 key(s) and has no DEBT entry" rather than passing.
2026-09-24 05:58:55 +00:00
doxa-maps-88655c70
b4902a7fed test(i18n): ratchet down — five locales now must stay complete
Some checks are pending
build-gate / build (push) Waiting to run
The ceilings drop as the debt is paid: 429 -> 344 keys, and ar/es/fr/pt/ru
reached zero.

Those five are REMOVED from the DEBT map rather than set to 0. The gate already
treats a locale with no entry as "must be complete", so deletion is what
converts a finished translation into a permanent guarantee — setting 0 would
read the same but leaves a line inviting someone to raise it.

Confirmed the guarantee works before relying on it: deleting one Spanish key
reports "es is missing 1/176 key(s) and has no DEBT entry" rather than passing.
2026-09-24 05:53:31 +00:00