"Where the folder goes" had two routes. The third is the same container with ACCESS=public on a
team's research subdomain: the Plugin Studio open to everyone (following development), its /cdn
built from main serving the site's production maps, and the admin parts behind a password set at
deploy. A small table ranks the three by capability: drop-in (the simplest, no backend),
subdomain (PocketBase behind a sign-in), Plugin Studio (the highest: plugins built live in
public). The two links to the section follow its new name.
The link to deployment/cloud's "Public Studio (ACCESS=public)" section resolves once
repo/public-studio-mode lands, so this lands after it.
/pb already refused them to anyone who is not an admin. /api-prod, the production PocketBase's
public API, passed every /api/ path, so its superuser sign-in was reachable from the public side:
no data was exposed, but it was a place to guess the production admin's password. Now any
/api-prod/api/collections/_… path is refused (403), checked on the decoded path like the /pb gate.
No public client calls these; the one caller in the repo (pb-oidc.mjs) talks to PocketBase directly.
Found by dx-base's independent review of the public Studio (V2).
The resolved config in ACCESS=public (Vite 5.4.21) had strict file serving with /app/repo as the only
allowed root, and a deny list of Vite's defaults (.env, .env.*, certificates). The .git folder sat
inside the allowed root and was not denied, so a public Studio could serve the repository's git
internals. In public mode the deny list adds **/.git/**; the defaults are restated because setting
deny replaces them.
The container needed an OIDC provider to run PocketBase at all. With ACCESS=public:
- The Studio, every map and the backend's public API are open, with no sign-in. Vite owns
the public port and forwards /cdn/, /api-prod/, /pb/ and /admin/ to admin.mjs, whose gates
decide.
- /admin/ (publishing), PocketBase's dashboard and _superusers ask for ADMIN_USER /
ADMIN_PASSWORD over HTTP Basic, compared in constant time. The container refuses to start
without ADMIN_PASSWORD. In this mode X-Forwarded-Groups is never read: with no oauth2-proxy
in front, it is whatever a visitor sends.
- Rudder's voice engine stays off. Gated bundles (gatedProfiles) are refused even by URL:
source, @fs, dev leaf. Vite's /__open-in-editor is refused.
OIDC stays the other mode, unchanged. In BOTH modes the Studio's own /api/pb (the dev backend
plugin) now goes through admin.mjs's /pb (DX_PB_URL). The dashboard and _superusers stay
behind the same gates, and plugins that use PocketBase work in the container's Studio; they
got a 503 before.
Checked: bash -n; the mode selection (public / gated / open); a missing password stops the
container. The admin gate and the public guard, taken from these files, pass 20/20:
- password right / wrong / wrong user;
- a spoofed groups header ignored;
- 401 + Basic prompt;
- gated mode unchanged;
- no password = no admin;
- every form of a gated path refused and the rest passed.
library/i18n/locales/README.md arrived in this landing, and every tool that
lists that folder read it as a locale: check:plural-forms and
check:list-prop-unit crashed on scandir/open of README.md, and
check:optional-t-equivalence reported six keys missing from locale
"README.md". The build's own area scan reads the same folder. The note is
now library/i18n/CATALOGUES.md, with one line saying why it is not inside.
library/packet/poster-render.js and posterSheets.js arrive in this landing
carrying the generator's comments as they stood before the sweep: two lines
named the Driver and one said "he changed the SHAPE". They are reworded as
the sweep reworded them in generate.mjs, and two pronouns the sweep left
behind (SearchFilterHeader, check-filter-width-stable) now say what was
asked instead of who asked. The baseline drops the two generate.mjs lines
that moved out.
The Driver's print standard: every export is generated when it is asked for, and only previews are
stored. The zone and country map sheets — the poster, and the map-only sheet on a packet's page 1 —
existed only if library/scripts/posters/generate.mjs (Node) had written them ahead of time; no country
had a print-resolution sheet at all.
- library/packet/poster-render.js is generate.mjs's drawing layer (2,645 lines) as a pure factory,
createPosterGenerator({ env, SERIES_DEF, ISO3_NAME, adm0, adm1Ind, adm1Chn, nePlaces, series }). Moved
VERBATIM (template literals carry their whitespace into the output), `process.env` read through a
local shadow; its inputs are its free variables, found by a scope-aware parse, not a grep (a grep
missed adm0, and the proof caught it). generate.mjs is now its Node driver.
- PROVEN BYTE-IDENTICAL: all 118 sheets of a pinned offline matrix — the 28 zones in 3x4, 3x3, letter
and packet, and a country sample at both sheet sizes — hash the same before and after.
- data/zone-sheet.js is which group pins in which zone (first claim wins), now ONE function both the
Node driver and the browser call, so a group cannot pin in one zone on paper and another on screen.
- library/packet/posterSheets.js feeds the factory in the browser: the served full-resolution
boundaries, the shared series and places tables, a generator per call (caching by format pinned the
first caller's data on every later sheet). generateCountrySheet does a country end to end;
data/zone-sheet-print.js a UPG-100 zone. ISO3_NAME moves to library/data (territory-counts re-exports).
- usePosterPrints gains opts.sheet: the poster print is the generated letter sheet as a VECTOR page,
and the packet's page 1 the generated map-only sheet (item.mapId honoured); stored images remain the
fallback when generation fails. PosterWall forwards it; the Country Posters wall, the UPG-100 wall and
the UPG-100 map pass it. The UPG-100 wall and map drop their own record fetches for the shared
provider, and the map's legend and selection HUD offer the card deck (production).
- check:poster-browser-equiv: the browser feed's sheet is byte-identical to the Node generator's for an
India zone (packet) and a country — proven two-sided (it fails when the feed drops the city dots).
check:selection-kinds reads the handler's branches instead of a fixed poster/packet pair, anchored on
`if (` (proven: it fails with the cards branch deleted). library/packet/README.md: the standard.
gates:pre 68/68; both bundles build.
An English _note key sat in gospel.json in all eleven locales, so every
Arabic reader downloaded an English paragraph about how the file is
organised. It also fooled a gate: because it was text present in ar and
absent from en, it satisfied a probe asking "does this bundle contain
Arabic translations?" for a bundle with every Arabic character stripped
out. A value no reader is meant to see is a value no check should be able
to count.
The reasoning it carried is real and is kept — which pools were searched,
why a mistranslated caveat there would read as authoritative, why Open
Bible Stories is not Scripture — moved to a README beside the catalogues
along with the rule that nothing in those files is a comment.
Adding that README turned check:i18n-parity red, which is the second half
of this commit: it enumerated locales with existsSync, true for a file as
well as a directory, so the README read as a locale missing all 1,685
keys. Documentation belongs next to what it documents; the filter was
wrong. Locales are directories now.
2.7 KB out of every bundle, and gospel.prose.english — the one translated
line that says the prose stays English — is untouched.
The area arm asks whether every area a bundle uses is present. It names
locales nowhere, so it is equally green whether a bundle carries eleven
locales or one. That is fine while the glob inlines them all, and stops
being fine the moment the build narrows by locale: a per-locale file that
failed to emit would fall back to English silently, with no error and no
gate. Written now, while the answer is still "all inlined", so it is
already guarding when the build changes underneath it.
A locale counts as reachable if it is inlined in the IIFE or emitted as
<bundle>/locales/<lc>.json beside it, which is the shape the design
proposes. The list comes from SUPPORTED_LOCALES, not from the folders on
disk: the folders are what happens to exist, while SUPPORTED_LOCALES is
what the runtime will actually try to resolve, and a locale added to the
list without a catalogue is precisely the failure to catch.
Three things it had to stop reporting, each a correct absence:
Bundles that include no areas carry no catalogue at all. Composite hosts
mount leaf bundles at runtime and render no catalogue text of their own —
stack-demo makes zero translator calls in its own source, and failing the
host would send someone looking for a bug in the wrong file. And the probe
was asking bundles for text from areas the build deliberately left out,
which reported five bundles as broken when none of them were.
The probe requires a locale's own script for ar, ru, hi and zh. "Not in
en" is not the same as "is a translation": the ar catalogue holds an
English developer note that en does not, and that one string was enough to
report ar as present in a bundle with every Arabic character stripped —
the check voting on a string no reader will ever see.
Proven both ways on real output. Stripping ar from a built bundle fails
naming ar; restoring passes. Simulating the design's end state — ar not
inlined, ar.json emitted — passes with the asset present and fails when it
is deleted.
gospel's resources-list maps the resource titles that arrive in the data to
the i18n keys that render them:
const RES_KEY = { 'Bible video': 'gospel.res.bibleVideo', ... }
title: RES_KEY[r.title] ? tr(RES_KEY[r.title]) : r.title
The English on the left is matched against a value from the data; what the
reader sees is the key on the right. Wrapping the left side in t() would
make every lookup miss and fall through to the raw data value, so the count
would drop while the display broke — the worst shape a gate finding can
have. Found by gospel, who triaged it rather than acting on it.
Exempt only when the mapped key really resolves in every locale, the same
way a fallback has to earn its exemption. A table pointing at a key nobody
wrote still counts: the lookup succeeds and hands t() a key the catalogue
has never heard of. A value that is a plain category rather than a key
counts too — the gate can only vouch for what it can resolve.
Self-test covers all three arms, using the real RES_KEY case. Poisoning the
exemption to skip the resolution check trips it.
gospel 153 -> 146; fleet 159 -> 152.
poster-wall translates its section headings by looking the key up by the
English: one table maps a zone id to a heading, a second maps that heading
to a catalogue key, and the helper returns the raw English when the second
table has no entry. Two headings reachable from four zone ids had no key, so
they printed in English in all eleven locales. upg100 found and fixed that
one; this is so the next table cannot repeat it.
The hardcoded-English scan cannot see this class. There the English is a
lookup index rather than a rendered label, and the key is computed, so a
scan that reads keys written literally at the call site has nothing to
check. Resolving arbitrary key expressions is a static-analysis problem;
this does the decidable part — two object literals in one file where the
values of the first are the keys of the second, and coverage is a set
difference. The table list is declared, not discovered, because a heuristic
hunting for related-looking objects would be guessing about code it has
never read.
Two things the check has to get right and could quietly get wrong. It
compares the VALUES of the producing table, not its keys — the ids are
never translated, and reading them finds nothing while looking like a
check. And a label passed straight to the helper as a literal counts as
produced: three of the nine headings never appear in the table at all, and
"Other" is exactly the catch-all that gets added last and keyed never.
A renamed table fails loudly rather than comparing two empty sets and
calling it agreement. The self-test covers a covered table, a gap, a gap
reachable only through a literal argument, and the renamed case; poisoning
either parser or the object matcher trips it.
Verified against both real states: development's poster-wall fails naming
Americas and Europe, and upg100's fix passes.
RegionCountryMap builds its map popup by concatenation and escaped every value
in it -- the name, the religion, the description, the image, both button labels
-- except the two URLs:
<a class="cm-popup__btn" href="${profileUrl}">
Both come from joinBase(props.researchUrl, p.slug), and joinBase says plainly
that it only normalises slashes. So two untrusted values reached an HTML
attribute raw: the slug, which is API data, and the base URL, which is an
attribute set by whoever embeds the element. With a slug of
`x" onerror=alert(1) data-x="` the rendered tag is
<a href="https://doxa.life/research/x" onerror=alert(1) data-x="">
out of the attribute and into new ones. esc() was already imported in that file.
The same omission in library/packet/packet-render.js, found by sweeping for the
class rather than assuming it was one site: the people-group card writes
src="${src}" where src is photos.get(slug), and usePosterPrints builds that Map
directly from the API's image_url. esc(g.name) sits two lines below it.
Neither could be caught by review or by template analysis -- both are inside
template literals -- and the popup had never once rendered, because the Regions
map was grey. So the gate: no href= or src= in built HTML takes a raw
interpolation. Data URLs, blob URLs, encodeURIComponent and safeUrl() are
recognised as safe, each verified at its call site rather than assumed from the
name; anything else needs an href-escaped-ok: reason in the four lines above.
Two such reasons are recorded, for the two sites the check cannot see the safety
of: useMapAttribution's hrefs are module constants selected by engine type, and
export-public builds its rel entirely from encodeURIComponent one line earlier.
The sweep also cleared seven sites it first flagged -- three blob: URLs from
createObjectURL, two data: URLs from readAsDataURL, safeUrl(), and the
encodeURIComponent pair -- so the check now recognises those shapes instead of
reporting them.
Mutation-tested: reverting either fix alone turns the gate red. 6 self-tests
carry the founding bug and the safe shapes. gates:pre 65/66, the one red being
check:poster-action-print waiting on gospel's GiTree fix.
This gate reported a 403 as "tileset entitlement, not the embed" and printed
only the first blocked URL. Both halves were wrong.
The claim is disproved. Measured on this gate's own token: /v4/ streets-v8 and
country-boundaries both return 200 -- with no Referer, and from
research.nuxtbase.app, maps-dev.nuxtbase.app, doxa.life, 127.0.0.1 and
example.com. The token is unrestricted and entitled. The old conclusion rested
on three no-Referer probes, which cannot tell a URL-restricted token from a
missing entitlement; it named a cause those probes could not support. Whatever
403s is a different request -- a style, a sprite, glyphs, a composite -- or a
different token than the one measured.
Printing one URL is what hid that. A leaf blocked on forty tiles showed
whichever tile lost the race, so the tileset was never legible and a second
blocked endpoint was invisible behind the first.
NOT MEASURED now names every distinct blocked path, with tile coordinates
folded to {z}/{x}/{y} so one blocked tileset is one fact rather than forty, and
styles, sprites and glyphs stay separate -- which is exactly the class the
evidence now points at. It also names where the token came from (env var or the
leaf's own baked one), never its value: the query string is stripped before any
path is printed, asserted in the exercise below. The real-failure arm gets the
same treatment.
The gate asserts no cause any more. It reports what was blocked and lets the
reader judge.
Exercised on six URLs spanning two tilesets, a sprite and a glyph range: 6 ->
4 distinct facts, and a planted token value does not appear in the output.
The map-ness scan follows a profile's own imports, which was the fix for a
map-less list inheriting map-ness from its bundle. It follows RELATIVE imports
only, so a component that moved into library/ became invisible: the profile
reaches it through @map/..., the alias resolves nowhere in the walk, and the
profile reports no map.
MEASURED on the seven curated tabs, alias imports resolved and re-tested:
exactly one was wrong. regions-dashboard/regions-mfe draws a real map through
@map/components/maps/RegionCountryMap.vue, which does match the engine test --
it imports useMapInstance and useMapProvider -- while region-dashboards.vue
holds no engine token of its own. The other five map tabs carry an engine token
in their own files too, which is why only this one showed the symptom. It is
not a recent break: regions-mfe is isMap=false in all eight manifest revisions
in history.
The consequence was a blind gate. check-mapbox-drew takes its targets from this
flag, so it measured six of the seven tabs and silently omitted the one with an
open map bug. Map leaves go 6 -> 7. country-map also flips to true, correctly:
it renders the same component.
A stricter test applies once the walk crosses into library/. The engine pattern
matches the bare string maplibre-gl, which in a shared library is mostly prose
and CSS class names -- 12+ library files match it while using no engine. The
one that bit: wall-of-people-groups, a D3 wall with no map, imports
src/wallD3.js, which imports library/constants/cdn.js, a pinned URL and SRI
table that only NAMES the engine CDNs. With the loose test that wall flipped to
isMap=true and would have baked ~400 KB it never uses, which is the regression
the per-profile scan exists to prevent. Inside library/ a file now counts only
if it imports the package, calls useMapInstance/useMapProvider, or touches a
map global. Plugin files keep the original test, so no in-bundle result moves.
Probe in _qa-cycle: nine cases including two reverse poisons -- regions-list and
wall-of-people-groups must stay false. It reads the shipping functions out of
this file rather than reimplementing them, and it fails on the code as it stood
before this change.
gates:pre 65/66. The one red is check:poster-action-print, which wants gospel's
GiTree fix that is not on this branch yet.
Two holes studio found in admin.mjs, there since 2026-09-27:
- proxy() parsed the remaining path as a URL. /pb//evil.example/x is a protocol-relative URL, so
admin.mjs fetched ANOTHER host (any internal address too) with the visitor's own headers, and
returned its body. The path now goes onto a fixed http://127.0.0.1:<port> origin and is never
parsed as a URL, and a path starting with // is refused.
- The /pb/_/ and _superusers checks tested the raw path, so /pb/%5F/ and
/pb/api/collections/%5Fsuperusers/... passed them, and PocketBase decoded them to the real
thing. The checks now run on the decoded, slash-collapsed path. Encoded slashes or
backslashes, and undecodable input, get 400.
Reach today: maps-dev answers all four attack paths with its sign-in page to an anonymous
visitor (measured), so a signed-in team member could use them. In ACCESS=public they would
be anonymous; that mode is held until this lands.
Checked both ways against a PocketBase-like upstream plus an "evil" host. The old code let
/pb/%5F/, %5Fsuperusers and %2F through, fetched http://_/ and hit the evil host once. The new
code refuses them (403/400) with 0 evil hits, and health and records calls still pass.
The legend's card-deck item is opt-in: it sits behind v-if="exportCards",
a prop that defaults to false. countries-map passes it and handles 'cards';
upg-100-list passes nothing, so its legend never shows the item and cannot
emit it. The gate still demanded a 'cards' branch in upg-100-list's handler
and failed gates:pre on a menu item that plugin never renders.
An item guarded by a prop now needs a handler only where upg-100-list passes
that prop. Both sides planted: upg-100-list opting in without a branch
fails, and the legend losing the guard fails.
check:optional-t-equivalence evaluates useOptionalT.js on its own, with its
imports stripped and tStatic and useI18n handed in. The composable now also
imports getRuntimeLocale and cldrChoiceIndex, so every section died with a
ReferenceError. The gate now hands in a bare host's getRuntimeLocale and the
shipped cldrChoiceIndex, lifted from library/i18n/index.js's own text; its
import control accepts tStatic among several names, but still only from
'@map/i18n/index.js' (a catalogue import of the module's own still fails).
check:frozen-labels caught the research filters' new search placeholder:
tStatic inside a computed that reads no locale never re-runs, so the
placeholder stayed in the boot language. It now reads and passes
locale.value, like the search chip in the same file.
Its scope note named the Driver while stating the poster rule;
check:no-new-driver-references fails on it. It now states the rule alone.
(GiTree.vue's line of the same kind was reworded by its owner on
development first.)
- The legend's packet submenu emits 'packet:full' | 'packet:lightweight', and the country map compared
against a bare 'packet', so its legend Packet export did nothing at all. It splits the kind first, as
the UPG-100 map's handler does (the research map carries the same three lines; reported to its owner).
- The selection's rows come from the print standard's records provider by slug (full print fields,
the reader's language); the rows mapped by hand put the country's NAME in country_code and carried no
language, so every profile description ran short. They remain as the fallback. The live map's raster
goes in through the mapImage seam, so page 1 is the map full page.
- The legend gains an OPT-IN Card Deck export (exportCards; provide('legendExportCards') for the mobile
sheet), so a map that does not handle it never shows a dead item.
- library/print/experimentalExports.js is the ONE switch for rolling the standard's exports out on
maps that are not production print surfaces (production: the UPG 100 list, the Regions dashboard's
country posters, the Country Posters wall). This map's card deck sits behind it; off — per page, per
visit (?exports=off), per site or everywhere — restores the menu it had before.
- The records provider returns coordinates (a generated map's pins), exposes the unselected set
(recordsFor.all) and sorts in the reader's collation.
A second look: the bounded set on one page and the centered set on another. Each chart
keeps everything the redesign gave it — its heading, the cross at the centre, the nine numbered
target bands with dots spread through them, the full ring legend — as one large square panel (960px)
centred on its page, instead of two 660px panels sharing one. The centered set's page carries the
legend; both carry the pair's heading so they read as one spread.
Also the profile row's photo URL is escaped (esc(src)), the identical hunk regions landed in
1aeafead0, so the two merge cleanly: a quote in an API image_url closed the attribute and injected
markup into the printed document.
check:packet-a1-structure and check:packet-variants assert the two-page order and the derived
lightweight delta (four back pages dropped).
check:poster-action-print asserted that PosterWall defaults to printing and
that any consumer passing posterAction 'download' explains itself. Both held,
and both missed the case that actually shipped: a component that never touches
PosterWall and writes its own <a download> for a poster image. GiTree had a
"Download SVG" button as the sibling of its own printPoster(), same
makePoster() behind each, so the poster could reach disk without the print
dialog ever opening.
Second arm: no shipped file may set a.download to an image or document name
(svg/png/webp/pdf/jpg) unless the line is reached only after a print attempt
failed (if (!opened) / if (!w)), or is the prop-gated opt-in the first arm
already polices, or carries a posterAction-download: reason. Data exports stay
out of scope -- csv, txt and json are not posters, so the five legitimate CSV
and letter exports stay green.
Keying on the filename at the call site rather than on the helper matters:
four of the seven download sites pass a variable, so a check written against
the helper would have passed them without being able to see what they wrote.
Six self-tests carry the shapes: the GiTree line fails, the print-blocked
fallback passes, the gated opt-in passes, the explained exception passes, csv
and txt pass. They are the proof the arm still bites now that no offender is
left in the tree.
The route table put both under OIDC_ADMIN_GROUPS. admin.mjs checks the admin group on the
build only; status answers any signed-in team member (commit SHAs and build state, nothing
more). Measured: a designer reads /admin/status (200) and is refused the build (403, "builds
are for team admins").
Three bugs rega found on a laptop run of the container (2026-09-29):
- /pb/_/ never rendered. admin.mjs proxied with fetch(), which returns the body decompressed,
but forwarded PocketBase's Content-Encoding: gzip and Content-Length, so the browser tried to
gunzip plain HTML. The proxy now drops both headers (for /pb and /api-prod).
- PocketBase's own redirects left the mount: /pb/_ went to /_/, and /pb/_/index.html to
http://127.0.0.1:8090/_/ (the internal address). Root-relative and upstream-absolute
locations are put back under the mount: /pb/_/.
- The research host's /embed-test set no MAP_APP_API_URL, so the maps asked that static host's
own /api (404s; dashboard-plus-grid drew 489 nodes, not 1,399). The page now sets it from the
manifest's runtime.apiBaseUrl before any bundle loads, as every generated page does.
Checked both ways against an upstream that answers like PocketBase (gzipped HTML, redirects
to /_/). The old proxy sends a gzip label on a plain body and /_/ locations; the new one
sends a decodable body and /pb/_/.
An absolute embed address works where it was built and breaks everywhere
else, which is how the CDN route failed and was retired. The build config
keeps embedBaseUrl empty, so the committed tree is relative and a deploy
makes it absolute at build time (EMBED_BASE_URL). But nothing stopped a
tree built with EMBED_BASE_URL from being committed: check:drop looks for
root-relative "/..." references and deliberately skips https:// ones.
With embedBaseUrl empty in the config, the manifest must say embedBase "."
and every scriptUrl, iframeUrl and embed snippet src must be relative. Data
addresses (runtime.apiBaseUrl) are not the tree and are not checked. SKIPs
when the config sets embedBaseUrl. In gates:post, after check:drop.
Today's tree: PASS, embedBase "." and 173 embed addresses, all relative.
Poison-tested, each fails: an absolute snippet src, an absolute embedBase,
a protocol-relative iframeUrl, an empty manifest. A real deploy manifest
built with EMBED_BASE_URL fails with 174 findings, as it should.
gates:post: PASS 82, NOT MEASURED 13 (browser/server), FAIL 1: the known
check:packets-all worktree failure (no generated zone packets here).
Eighteen keys across eleven locales for text that was written in English in
the components: the rail and filter toggles, the selection count, the CSV
export toast, the drawer's prayer and information links, the dashboard
title, the map's failure message, and the four search placeholders that name
the tiers of whichever tab is open.
The export toast is interpolated rather than concatenated. Its counts keep
going through fmt(), which groups digits the way each locale does, and only
the words around them come from the catalogue — the order of "rows",
"fields" and the outcome is not the same in every language, so a sentence
built by joining fragments cannot be translated at all. It uses the shared
fillParams, which leaves a placeholder visible when a value is missing
instead of rendering the word "null".
The map's failure message no longer names the token and the container: that
is for the console line, which stays English for whoever is debugging. What
the reader sees says what to do.
Three more shapes the scan could not see, each of them correct code: tStatic
called directly rather than through a wrapper; `config || t(key) || English`,
where the translator is not the immediate left operand because || is
left-associative; and the licence credits, media queries and product name
that must not be translated at all.
Own leaks 91 -> 1, the last being a tab label behind a key that resolves in
all eleven locales. Fleet 249 -> 159. check:i18n-parity,
never-translated, treeshake and stopped-scope all pass.
The legend emits 'packet:full' or 'packet:lightweight' — it widened its
existing event rather than adding a second one — and this router still
tested the whole string for 'packet'. Neither submenu item matched any
branch, so the Packet export opened nothing, logged nothing, and looked
like a menu item nobody had wired. Found by upg100, who had already hit
the same thing in its own copy.
The kind is split before it is compared, the way upg-100-list does it, and
a bare 'packet' still means the full packet so an older emitter keeps
working. The variant then has to survive the call as well: openPacket
ignored it and printPacket defaults to full, so lightweight would have
printed the full packet even once the menu fired.
The gate asserts the seam rather than one file — it reads the kinds the
legend actually emits and requires every consumer to split before
comparing, because this bug cannot be seen by reading either side alone.
It currently fails on country-posters/countries-map.vue, which has the
identical dead menu and is not mine to change.
Two fixes to the check itself while writing it: a fixed-size window read
only part of a long router and reported correct code as broken, so the
function's extent is found by brace depth; and a trailing \b after a
quoted literal never matches, which made the assertion fire on every
consumer including the working ones.
tStatic is the library's translator for code outside a component's setup, and
it was missing from the set of names the scan knows. Anywhere it was called
directly rather than through a wrapper, correct guarded code read as a leak.
The two-sided rule still applies to it: a tStatic fallback is exempt only
when its key resolves, and the self-test asserts both halves.
Three allow shapes, each for a string that must not be translated:
A matchMedia query. Rendering "(prefers-reduced-motion: reduce)" in another
language matches nothing, so motion safety would switch off for exactly the
readers who asked for it, and nobody would see an error.
Tile licence attribution. The credit a data licence asks for is the one
string that stays as the licensor wrote it in every language. These render
only when the live sources cannot be read, which is the case a licence never
allows to come out empty.
The product name, and bare CSS position keywords.
Each shape is anchored rather than fuzzy: prose that resembles them —
"Start end of the list", "center the map on your region", "Doxa Maps is a
prayer tool for the unreached", a copyright line with a real sentence in it —
is still reported. 190 -> 178.
The legend gives each tab a small "i" button explaining what that tier
means — what an Affinity Block is, how a people group differs from a people
group in a country. SemanticTreeLegend already read that text from
legend.tabInfo.<id> behind a te() guard, and not one of the ten keys the
research map needs had ever been written. The tab chips were translated in
all eleven locales and the explanations beside them were not, so the reader
least able to guess what "PGIC" means was the one reader who got it in
English.
The keys are written for all eleven locales, following the terminology each
catalogue already uses for these same tiers — People Group as Volksgruppe,
Peuple, Pueblo, 民族, Народ, जनसमूह — with WAGF and UPG left untransliterated
as the corpus leaves them. The reader-facing text drops the source field
names and row counts that the English carried; those are notes for whoever
maintains the tables, so they stay in the source as a comment.
With the keys in place the English in the tab tables can never render, and
a second copy of a string drifts from the catalogue without anyone noticing,
so it is removed. `info: true` stays because the legend gates the "i" button
on that field being set — dropping it would have removed the affordance
instead of translating it — and tabInfo now treats only a string as a
fallback, so the flag cannot reach the popover as the word "true".
Verified every changed tab renders a real label and a real explanation in
all eleven locales. check:i18n-parity, check:i18n-never-translated and
check:i18n-treeshake pass; hardcoded-english 205 -> 190.
useOptionalT's fallback (the path a bundle with no vue-i18n takes) picked a plural form by English's
rule for every locale, so "21 народ" and Arabic's six forms came out wrong there — regions caught it
before wiring their wall. The fallback now uses the SAME CLDR selection library/i18n/index.js installs
(cldrChoiceIndex, now exported), and English keeps vue-i18n's own rule, so both paths print the same
form. Checked against the real code: ru 1/3/5/21, ar 0/2/5/11, en 0/1/7.
The country wall passed no records, so each of its 175 cards offered only the poster and said the
prayer list was "coming soon", while the people groups were one request away. library/lists/
peopleGroupRecords.js is the records half of the print standard: one people-groups fetch per language
(normalised the way the prints read rows, list membership applied), then a SELECTOR picks the set — a
country by ISO3, explicit slugs, any field, or a host's own selector language. A selector nobody
understands selects nothing rather than every group on earth, and a failed fetch is not cached.
The country wall uses it by ISO3, so every country now prints the list, the deck (all five stocks) and
the full or lightweight packet. The packet's page 1 is, for now, the country's stored poster when it is
really there; the in-browser map generator will replace that.
check:people-group-records asserts the selection two-sided with an injected fetch; registered in
gates:pre.
Brief A1.4 asks the page to state how many people each cell represents; it printed the share and the
population but never the unit a reader needs to read the grid ("each box ≈ 40,208 people" for the
whole DOXA list). Computed with the screen chart's own peoplePerCell so paper and screen divide the
same way, in all eleven languages, and omitted when the list has no published data.
library/layouts/app-wrapper-material/index.html carried the one library
reference the sweep's file filter missed (it looked at scripts, components
and docs, not .html): reworded the same way, comment only.
The reference baseline then shrinks from 505 to 147 lines, so nothing
reworded in this landing can come back. What remains is in the owners'
plugins, documentation and skills.
Thirteen lines arriving in this landing named or quoted the Driver: the
packet renderer and its two gates, and the desktop build. check:no-new-driver-references fails on each of
them, so they are reworded here the same way as the sweep: the date and
the requirement stay, the speaker and the quote go.
Every reference to the Driver in internal/ is reworded the same way as in
library/: the reason stays, the speaker and the quote go, an open decision
reads "provisional, pending a product decision", and agent names and decision-
sheet rows are dropped. Where a gate printed the word, its message now names
the reviewer or the ruling instead.
Three spots were code, not comments, and changed with their callers:
- the element census's allowlist field `driver` is now `ruling`, in the gate
and both presets (same validation: match, ruling, date and card required);
- check:i18n-never-translated's DRIVER-GATED marker is DECISION-PENDING, in
the entries and in the regex that counts them (its output is otherwise
byte-identical);
- the package.json description of the census says "ruling".
public-hygiene-check keeps its two patterns for the word: they are what keep
it out of the served folder.
Every reference to the Driver in library/ is reworded: the engineering reason
stays, the speaker and the quote go, and a decision still open reads
"provisional, pending a product decision". Comments only: each changed file
is identical after stripping comments (JavaScript, template and CSS, including
CSS comments inside the style strings shadow-DOM components inject).
Those style-string comments do ship, so several bundles change by exactly
those comment bytes.
studio measured the trap: t(key) with no params BLANKS every placeholder. On
vue-i18n 9.14.5 "{nNone} of {nLang} have {noScripture}" comes back as " of have "
— so sentenceParts would find nothing to split on and the sentence would lose its
values, silently, in every locale at once. I reproduced it before building on it.
keep(...names) returns { name: '{name}' } to hand t(), making the lookup a no-op
over the placeholders. Preferred over tm(), which returns the raw string only
while messages are compiled at runtime; this works with precompiled messages and
with plurals, which is the point of the pattern.
The previous commit SAID it works with precompiled messages. It was not measured,
and it was wrong in the way that matters: the helper was right and the call
pattern around it was not, so every arm passed while the real thing would have
rendered a blank sentence. Three new arms run an actual createI18n — English
renders, Arabic reorders the clauses, and the negative control asserts that
WITHOUT keep() the placeholders really are blanked, so if vue-i18n ever stops
doing that we find out rather than carrying keep() by habit.
T('dashboard.map_loading', 'Research map loading…') is the idiom these
components use: the English is what shows if the catalogue ever loses the
key, not what the reader gets. Forty-four findings were code of exactly
that shape, and triaging them meant reading catalogue entries that were
already complete in all eleven locales.
Two things were wrong. Wrapper aliases were not recognised as translators
at all — only `const X = t` was, never `const T = (key, en) => ...` — so
every fallback in those components read as a leak. And the rule that
exempted translator arguments exempted all of them, including the fallback,
which left the gate structurally unable to see a typo'd key: the English
then renders forever while the call site still looks correct.
So the key argument stays exempt and the fallback argument has to earn it,
by resolving to a real value in every shipped locale. A missing key still
counts. An identical rendering counts only when the English has three or
more translatable words: measured against this catalogue, the identical
entries are cognates (Normal in de, Auto in fr), proper nouns, and
format-only strings like "{label} · {count} · {pct}%", and failing on those
would report correct translations as leaks.
Bound expressions are parsed on their own, so they could not see the
translator declared in the script block; the declarations are carried in as
stubs. The stubs deliberately contain no || — written the natural way they
match the te()-guard shape and exempt every fallback in the file, resolved
or not.
Self-test covers both sides: a resolved fallback is quiet in template and
script, an unresolved one counts in both, and the catalogue rule is
asserted directly. 249 -> 205 across the fleet, all 44 in this owner's
plugins; detection elsewhere is unchanged.
Triage meant grepping the reported string to find its site, and the first
textual match is very often a comment that discusses the string rather than
the code that renders it. Two findings were sited to prose that way, and a
count built on those sites was wrong.
Each finding now carries the line. <script> and <script setup> are scanned
separately rather than joined, because concatenating them made every line in
the second block wrong by the length of the first — a confident wrong site
sends the reader further off than no site at all. A bound expression is
re-parsed on its own, so its findings take the attribute's line instead of
the fragment's own numbering.
The self-test reads the reported line back out of the fixture and requires
it to contain the string, so an offset error fails rather than passing with
a plausible number.
Detection is unchanged: 249 before and after.
The pattern this replaces built a sentence by concatenating translated fragments
around untranslated text — "languages have", "of", "people." are each a separate
string in gospel-resources today. Arabic does not put those clauses in English
order, so translating the fragments yields Arabic words in English order:
grammatically broken, and worse than leaving it English, because it looks
translated and nobody re-checks it.
library/composables/useSentence.js takes ONE message with named placeholders and
a value per name. It renders sentinels, splits on them and drops the values back
in, so a translator moves the clauses freely and the markup survives as slotted
content: the bold counts and the dir="ltr" island are passed in, never written
into the catalogue. No <i18n-t>, and it works with precompiled messages.
Built to studio's four constraints: the caller passes a whole key literal (the
helper takes a MESSAGE and cannot assemble a key, so the build's area scan still
sees it); t comes from the calling component; a sentence with two counts receives
each already pluralised from its own key, since vue-i18n resolves one plural form
per key; and the sentinels are private-use (U+E000+n) so nothing in the pipeline
may rewrite them.
check:sentence-slots, 14 assertions, every arm with a negative control — and two
of them caught real defects in this change. A slot value of 0 rendered as an
empty string (the bug was in my test's flattener, not the helper — 0 is a real
count and "0 of 7,296" is a sentence a filter legitimately produces). And a
private-use character already IN a message was consumed as slot 0, printing
"a X b X" while the arm still passed because it only checked the value was
present; strays are now stripped before ours are placed, so every remaining mark
is ours by construction.
The gate measures the built folder, so it belongs after the build, in
gates:post, with its description beside the others. It now reads the tree
name from _tree-name.mjs like every other tool: the folder was renamed
once already, and a gate hardcoding the old name kept reading it.
The row now says 1205 files, 154 MiB: the folder as it stands with the
350 city and country posters this landing ships.
dx-base measured two defects in the A8 draft for text that goes public:
- Links into the private DX Life repo answer 404 to anyone without an account. The live
example is now plain words (README, DEPLOY), and the clone line points at the public repo
(doxa-open/doxa-maps, as DEPLOY already did). DEPLOY's "three ways" becomes the two routes;
the CDN route is retired.
- The web folder row said 536 files, 76 MiB. The folder is 855 files, 119.2 MiB. New
check:readme-size re-measures public/dx-maps and fails when the row is more than 10% off,
printing the line to paste. It passes at 855/119; the old numbers fail.
Rudder was measured slowing after about 1,400 words while a Tauri build
ran. The word count is incidental — it is a proxy for how long the build had
been going. The speech model runs on the CPU (backend/rudder-engine starts it
with --device cpu), and a Rust build saturates every core, so at equal priority
it starves the one process whose latency a person can hear.
Three changes, and the direction of the first is forced rather than chosen:
1. internal/desktop-build.mjs nices the whole build tree to 10, through the one
run() helper every step goes through, so cargo, rustc and the linker inherit
it. We nice the COMPETITOR because an unprivileged process cannot raise its
own priority on Linux — a negative nice needs CAP_SYS_NICE — so "promote the
ASR" would fail with EPERM. If nice(1) is missing the build still runs and
says so, because a silent fallback would look exactly like the fix working.
2. cargo is capped at cores-1. nice decides who wins a CONTESTED core; it does
not stop the build taking all of them, and a fully subscribed machine still
adds scheduling latency to the audio path. An explicit CARGO_BUILD_JOBS is
honoured rather than overridden.
3. yieldCPU reports its error instead of discarding it. It was
`_ = syscall.Setpriority(...)`, which cannot fail visibly — a call that
silently does nothing is indistinguishable from one that worked, and this one
guards something audible.
Also, one real O(n) in the widget, found while checking studio's answer that the
collapsed pill already shows the last words: sendActivity split the ENTIRE draft
on every activity tick to take five words. Measured at 3,000 words: 1.61ms per
tick against 0.051ms for a bounded tail, 31x, with byte-identical output.
Verify the priority work with `ps -o ni=` on the running cargo processes, never
by any of these calls returning.
Two halves, Node only. The helper, against fake responses: a real WebP
is accepted by type or by its bytes; a host's 200 fallback page is
refused as a raster and as a source; HTML labelled octet-stream is
refused; a 404 and a network error are misses for the raster; a network
error on the source is thrown as itself. The callers: neither
usePosterPrints nor PosterWall may fetch posters/ directly again, since
each once carried its own copy and a guard in one did not reach the other.
Poison-tested: restoring the old accept-anything fetch fails 3 checks,
and PosterWall's direct fetches failed the caller check before its switch.
gates:pre: PASS 67, FAIL 0.
PosterWall carried its own copy of the poster fetch, so the fallback-page
guard added to usePosterPrints did not reach it: a missing .webp answered
200 text/html by a host was still handed to the print dialog as the image.
It now uses fetchPosterRaster and fetchPosterSource. A thrown .webp fetch
is still a miss that falls through to the source, and a missing source
still reads "poster not found (<status>)".
On a drop-in host's dev server a missing posters/<id>.webp answered
200 text/html (the app's own page), and export handed that page to the
print dialog as the image. The .html source path had the same hole: the
rasteriser lifts the first <svg> on whatever page it is given.
library/packet/poster-fetch.js now fetches both. A raster counts only if
it is an image: by content-type, or by its first bytes when a host labels
a WebP application/octet-stream. A source counts only if it is a poster
document (its class="stage" element); a 200 that is not one reports 404.
A thrown fetch on the raster stays a miss, so the source fallback still
runs; a thrown fetch on the source is still reported as itself.
Measured against a real local HTTP server with a single-page-app fallback:
real WebP accepted (image/webp and octet-stream), missing WebP refused
(200 fallback page and 404), real source accepted, missing source 404
(200 fallback page and 404), unreachable host a miss for the raster and
"fetch failed" for the source.
usePosterPrints uses it. PosterWall follows in its own commit.