docs/solutions/design-patterns/closed-world-classification-gate-for-config-completeness.md
Desktop releases shipped for months with sign-in, subscription entitlements,
and the Cyber Threats layer silently disabled (#5905): the Tauri build
workflow passed a hand-picked subset of the VITE_* client env, and nothing
forced anyone to decide whether a newly added var belonged in the desktop
build. The web app got every var from Vercel env, so the omission was
invisible everywhere except the shipped binary. An opt-in allowlist rots
precisely because adding a member requires remembering the list exists —
the same failure documented for Railway seeder watch paths
(docs/solutions/integration-issues/railway-seeder-watch-paths-can-skip-deployments.md).
Structure the gate as a closed world over a mechanically enumerated universe, not as an opt-in list:
import.meta.env.VITE_*
read under src/ and shared/, matching cast/bracket/optional-chain
access shapes so syntax cannot dodge the scan).REQUIRED (mechanically asserted present at every consumer — here, every
tauri-apps/tauri-action step's env: block) or EXCLUDED (with a
one-line recorded reason: "web-seeded; desktop uses keyring", "feature
sunset #4982"). An unclassified member fails CI with a message naming
the member and the exact file/arrays to edit.CONCEPTS.md → Vacuous Guard, Mutation Proof).Reference implementation: scripts/check-desktop-build-env.mjs
(npm run desktop:check-env), wired into the desktop-config CI job
(fires on workflow edits) and the unit job (fires when the universe
grows — a new env read in src/), with the release-time non-emptiness
preflight in .github/workflows/build-desktop.yml. Shipped on PR #5919.
The forcing function moves the classification decision to the moment a
member is introduced — the one time its author has full context — instead
of leaving it to an audit months later. The recorded EXCLUDED reasons are
a decision log: the next reader distinguishes "deliberately not shipped"
from "forgotten", which is exactly the distinction the original incident
lacked. An opt-in list can only ever catch what someone remembered; a
closed world catches what everyone forgot.
Failure message shape that makes the gate self-serving (from the reference implementation):
::error::desktop build env: unclassified VITE_ vars read by the SPA:
VITE_NEW_FLAG — add each to REQUIRED_DESKTOP_BUILD_ENV or
EXCLUDED_DESKTOP_BUILD_ENV (with a reason) in scripts/check-desktop-build-env.mjs
Classification record shape — the reason is the point:
export const EXCLUDED_DESKTOP_BUILD_ENV = {
VITE_OPENSKY_RELAY_URL: 'web-seeded runtime secret; desktop uses the OS-keyring path instead',
VITE_ENABLE_IRAN_ATTACKS: 'feature sunset, default-off everywhere (#4982)',
};
Related: docs/solutions/conventions/verify-the-verifier-mutation-test-every-detection-layer.md
(prove the gate itself with mutations — the reference implementation's
fixtures kill missing-key, unclassified-var, zero-steps, and parser-evasion
mutants);
docs/solutions/integration-issues/railway-seeder-watch-paths-can-skip-deployments.md
(the enumerated-allowlist rot this pattern replaces);
docs/solutions/design-patterns/contract-gate-field-names-miss-value-axis.md
(a second instance of this pattern: proto fields as the enumerable universe,
with the block-commented-field evasion as exactly the parser-evasion mutant
class named above);
scripts/crawlable-sources-page.mjs sourceDomainIdForEntries (a third
instance: structured source-attribution providers as the enumerable
universe. SOURCE_DOMAIN_MATCHERS plus SOURCE_DOMAIN_OVERRIDES must
classify every provider, or build:full throws
Source provider needs a catalog domain: <name>. Matcher hits are
substring-fragile — USGS ScienceBase matched energy via commodity,
while British Geological Survey World Mineral Statistics did not match
mineral until that token was added. Prefer an explicit override keyed
to the exact provider display name when adding a new structured source.
Opened on PR #6527).