Removes the old planner/decision-critic/role-agents/domain-agents system (step-injector scripts, stale scopes, sprint-era workflow) and replaces it with 16 ground-truth-verified skills under .claude/skills/: change control, debugging playbook, failure archaeology, architecture contract, domain reference, OCR/Gemini pipeline, config and secrets, build and env, run and operate, diagnostics (with tested scripts), validation and QA, docs and writing, launch readiness, deploy-safety campaign, proof and analysis toolkit, and research frontier. RULE 0/1/2, the temporal-contamination rule, and the decision stress-test protocol are carried forward into the new skills; the retired content remains in git history. Co-Authored-By: Claude Fable 5 <[email protected]>
22 KiB
name, description
| name | description |
|---|---|
| mvp-config-and-secrets | Load when working with MotoVaultPro configuration or secrets in any form - adding/changing an env var, YAML config field, Docker secret, feature flag, tier gate, vehicle limit, or Stripe price ID. Also load on these symptoms - backend crashes at startup with "Configuration file not found" or "Secrets loading failed", "Secret file not found" errors in logs, a secret file on disk is unexpectedly a DIRECTORY, RESEND_WEBHOOK_SECRET is not configured, Stripe checkout returns wrong tier, VITE_ env var change has no effect until rebuild, Google Maps key missing in frontend, 403 TIER_REQUIRED, or confusion about which env vars a container actually reads. |
MotoVaultPro Configuration and Secrets Catalog
Authored 2026-07-07. All facts verified against repo code on that date. Where any doc contradicts this file or the code, code wins.
When to use / When NOT to use
Use this skill when you need to know where a configuration value lives, which env vars a service actually reads, what secrets exist and how they get onto servers, how feature tiers gate behavior, or how to add any of these.
Do NOT use this skill for:
- Deploy mechanics, blue-green, rollback, .env generation during deploys ->
mvp-run-and-operate - Getting a local environment running, missing-secret workarounds for local dev ->
mvp-build-and-env - OCR/Gemini engine knobs in depth (WIF auth chain, engine selection semantics) ->
mvp-ocr-gemini-pipeline - Why the config architecture is YAML-file based (design rationale) ->
mvp-architecture-contract - Triage of a live failure whose cause is unknown ->
mvp-debugging-playbook - Tier/subscription domain semantics (what tiers mean to the business) ->
mvp-vehicle-domain-reference
1. Config architecture: YAML + file secrets; env vars are the EXCEPTION
The backend deliberately does NOT use env vars for most configuration. backend/src/core/config/config-loader.ts loads:
- A YAML config file from
CONFIG_PATH(default/app/config/production.yml), validated by a Zod schema (configSchema). The file is the committed, non-sensitiveconfig/app/production.yml, bind-mounted read-only by compose. Database host/port/name/user, Redis, Auth0 domain/audience, CORS, health probe timings, and performance settings all live HERE, not in env. - File-based secrets from
SECRETS_DIR(default/run/secrets), validated bysecretsSchema. Each secret is one file, name matching the compose mount (no.txtinside the container).
The loader is a module-level singleton: export const appConfig = configLoader.load() runs at import time. Missing config file or a missing REQUIRED secret means the backend process throws before the server starts. It also self-injects two values into process.env after loading: RESEND_API_KEY always, RESEND_WEBHOOK_SECRET only if present (config-loader.ts lines 266-270).
Files (all committed):
config/app/production.yml- the live backend config (mounted to/app/config/production.yml)config/app/production.yml.example- STALE: mentions minio and admin-postgres hosts and lacks most sections the Zod schema requires. Do not copy it as a template; copyproduction.ymlitself.config/app/ci.yml- no references found anywhere in the repo as of 2026-07-07 (candidate dead config)config/shared/production.yml- mounted to/app/config/shared.ymlby compose, but NO backend code reads it (dead mount as of 2026-07-07)
Backend env vars that actually exist (verified against all process.env reads in backend/src/, non-test)
| Var | Read at | Default | Notes |
|---|---|---|---|
CONFIG_PATH |
core/config/config-loader.ts:155 |
/app/config/production.yml |
File must exist or startup throws |
SECRETS_DIR |
core/config/config-loader.ts:156 |
/run/secrets |
|
LOG_LEVEL |
core/logging/logger.ts:10 |
info (invalid values fall back with a console warning) |
Compose sets ${BACKEND_LOG_LEVEL:-debug} |
NODE_ENV |
app.ts (single read, health payload) |
none | Compose sets production |
STRIPE_PRO_MONTHLY_PRICE_ID, STRIPE_PRO_YEARLY_PRICE_ID, STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, STRIPE_ENTERPRISE_YEARLY_PRICE_ID |
features/subscriptions/domain/subscriptions.service.ts (plan-to-priceId map ~line 833 and reverse tier inference ~line 885) |
Sandbox price IDs baked into docker-compose.yml backend env |
Staging/prod override via .env generated from Gitea Actions variables. Wrong values = checkout works but tier inference breaks |
OCR_SERVICE_URL |
features/ocr/external/ocr-client.ts:8 |
http://mvp-ocr:8000 |
|
MIGRATIONS_DIR |
_system/migrations/run-all.ts:38 |
/app/migrations set in backend/Dockerfile:58; source fallback ../../../migrations |
|
TERMS_CONTENT_HASH |
features/terms-agreement/domain/terms-config.ts |
computed fallback | Optional override |
FROM_EMAIL |
features/notifications/domain/email.service.ts |
[email protected] |
|
BACKUP_STORAGE_PATH |
features/backup/domain/backup.types.ts |
/app/data/backups |
|
HOSTNAME |
features/backup/domain/backup-archive.service.ts |
mvp-backend |
Docker-provided |
RESEND_API_KEY, RESEND_WEBHOOK_SECRET |
email service / features/email-ingestion/external/resend-inbound.client.ts:30 |
none | NOT set from outside - config-loader injects them from secret files. See the webhook gap in section 2 |
DEAD env vars: DATABASE_HOST and REDIS_HOST are set on the backend service in docker-compose.yml but NO backend code reads them. Actual DB/Redis hosts come from the YAML config. Do not "fix" a connection problem by changing these; they do nothing. (Cleanup candidate.)
Frontend env vars (Vite - baked at BUILD time)
Vite inlines import.meta.env.VITE_* into the JS bundle during vite build. In this project the build happens inside the Docker image build, fed by compose build.args (docker-compose.yml frontend service) and, in CI, by --build-arg values from Gitea Actions variables (.gitea/workflows/staging.yaml lines 69-73). Consequence: changing a VITE_* value requires REBUILDING the frontend image. Setting it in .env or container environment after build does nothing.
| Var | Consumed at | Compose build-arg default |
|---|---|---|
VITE_AUTH0_DOMAIN / VITE_AUTH0_CLIENT_ID / VITE_AUTH0_AUDIENCE |
src/core/auth/Auth0Provider.tsx |
Real Auth0 tenant values |
VITE_API_BASE_URL |
src/core/api/client.ts, src/features/auth/api/auth.api.ts |
/api |
VITE_STRIPE_PUBLISHABLE_KEY |
src/features/subscription/pages/SubscriptionPage.tsx, mobile/SubscriptionMobileScreen.tsx |
none (empty = Stripe silently broken) |
VITE_LOG_LEVEL |
src/utils/logger.ts (default info) |
not passed as a build arg - container builds always get info |
import.meta.env.MODE === 'development' gates debug panels; always production in container builds.
EXCEPTION - runtime injection: the Google Maps API key and Map ID are NOT build-time. frontend/scripts/load-config.sh runs at container start (frontend/Dockerfile:81 CMD) and reads /run/secrets/google-maps-api-key and /run/secrets/google-maps-map-id (mounted from secrets/app/*.txt), writing them into /usr/share/nginx/html/config.js as window.CONFIG. So Maps keys rotate with a container restart, no rebuild. VITE_GOOGLE_MAPS_API_KEY in frontend/.env.example is for local npm run dev only.
OCR service env vars (ocr/app/config.py, all os.getenv)
| Var | Code default | Compose override (docker-compose.yml mvp-ocr) |
|---|---|---|
LOG_LEVEL |
info |
${BACKEND_LOG_LEVEL:-debug} |
HOST / PORT |
0.0.0.0 / 8000 |
not set |
OCR_PRIMARY_ENGINE |
paddleocr |
google_vision |
OCR_FALLBACK_ENGINE |
none |
paddleocr |
OCR_CONFIDENCE_THRESHOLD / OCR_FALLBACK_THRESHOLD |
0.6 / 0.6 |
0.6 / 0.6 |
GOOGLE_VISION_KEY_PATH |
/run/secrets/google-wif-config.json |
same |
VISION_MONTHLY_LIMIT |
1000 |
1000 |
VERTEX_AI_PROJECT |
"" |
motovaultpro |
VERTEX_AI_LOCATION |
global |
global |
GEMINI_MODEL |
gemini-2.5-flash |
gemini-3-flash-preview (compose wins in containers) |
REDIS_HOST / REDIS_PORT / REDIS_DB |
mvp-redis / 6379 / 1 |
same (OCR uses Redis DB 1; backend uses DB 0 via YAML) |
The compose defaults differ from code defaults - when reasoning about container behavior, read compose, not config.py. Engine semantics: see mvp-ocr-gemini-pipeline.
2. Secrets inventory (names and paths ONLY - never print values)
All app secrets live in secrets/app/ on the host and are bind-mounted read-only to /run/secrets/<name-without-.txt> in containers. Real .txt files are gitignored; only .example siblings and google-wif-config.json are committed (the WIF config is checked in by design - it contains no key material, it is a Workload Identity Federation descriptor).
Full set (matches scripts/inject-secrets.sh SECRET_FILES, 12 files):
postgres-password.txt, auth0-client-secret.txt, auth0-management-client-id.txt, auth0-management-client-secret.txt, auth0-ocr-client-id.txt, auth0-ocr-client-secret.txt, google-maps-api-key.txt, google-maps-map-id.txt, cloudflare-dns-token.txt, resend-api-key.txt, stripe-secret-key.txt, stripe-webhook-secret.txt - plus committed google-wif-config.json.
Who mounts what (base docker-compose.yml; docker-compose.blue-green.yml mirrors via anchors):
- backend: postgres-password, auth0-client-secret, auth0-management-client-id/secret, google-maps-api-key, google-maps-map-id, resend-api-key, stripe-secret-key, stripe-webhook-secret
- frontend: google-maps-api-key, google-maps-map-id (runtime injection, section 1)
- ocr: auth0-ocr-client-id, auth0-ocr-client-secret, google-wif-config.json
- traefik: cloudflare-dns-token (DNS-01 cert challenge)
- postgres: postgres-password (
POSTGRES_PASSWORD_FILE)
The backend config-loader loadSecrets() reads exactly these 9 names: postgres-password, auth0-client-secret, auth0-management-client-id, auth0-management-client-secret, google-maps-api-key, resend-api-key, resend-webhook-secret, stripe-secret-key, stripe-webhook-secret. All required by the Zod schema except resend-webhook-secret (optional).
Anomalies to know (verified 2026-07-07):
auth0-ocr-client-id.txt/auth0-ocr-client-secret.txtare mounted into mvp-ocr and required by inject-secrets, but NO Python code reads them (only a docstring mentions Auth0 M2M). Mounted-but-unread; do not assume OCR auth is enforced by them.cloudflare-dns-token.txtandgoogle-maps-map-id.txthave no.examplesibling.
Injection flow - editing secrets on servers is futile
On EVERY staging and production deploy, scripts/inject-secrets.sh regenerates ALL 12 secret files under $DEPLOY_PATH/secrets/app from Gitea Actions secrets (env vars POSTGRES_PASSWORD, AUTH0_CLIENT_SECRET, AUTH0_MANAGEMENT_CLIENT_ID, AUTH0_MANAGEMENT_CLIENT_SECRET, AUTH0_OCR_CLIENT_ID, AUTH0_OCR_CLIENT_SECRET, GOOGLE_MAPS_API_KEY, GOOGLE_MAPS_MAP_ID, CF_DNS_API_TOKEN, RESEND_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET). Any missing variable fails the deploy. Files are written chmod 644 inside a chmod 700 dir. google-wif-config.json is copied from the checkout by the workflows themselves (staging.yaml ~lines 123-125, production.yaml ~lines 115-117).
Therefore: to rotate or fix a secret, update the Gitea Actions secret (repo settings or mcp__gitea-mcp__upsert_repo_action_secret) and redeploy. Hand-editing /opt/motovaultpro/secrets/app/* survives only until the next deploy - and hand-editing server files is an owner non-negotiable violation anyway.
The missing-secret DIRECTORY trap
Docker bind-mounting a host file that does not exist silently creates an empty DIRECTORY at that path. Symptoms: backend logs Secret file not found or Failed to read secret file (EISDIR), then Secrets loading failed; ls -la secrets/app/ shows drwxr-xr-x entries ending in .txt. This is the current state of several secrets on the dev machine (as of 2026-07-07: auth0-ocr-client-id.txt, auth0-ocr-client-secret.txt, stripe-secret-key.txt, stripe-webhook-secret.txt are directories locally). inject-secrets.sh and both deploy workflows carry rm -rf cleanup for exactly this failure mode. Local fix: rm -rf secrets/app/<name>.txt then create the file with a real value (or, if you only need the stack to build, see mvp-build-and-env - do not invent fake Stripe keys and call billing "tested").
Known gap: resend-webhook-secret
resend-webhook-secret is in the config-loader's load list (optional in the schema) but: no secrets/app/resend-webhook-secret.txt exists, no compose file mounts it, inject-secrets.sh has no entry for it, and no Gitea secret feeds it. So RESEND_WEBHOOK_SECRET is never set anywhere, the backend logs Secret file not found: /run/secrets/resend-webhook-secret on every boot (harmless noise), and features/email-ingestion/external/resend-inbound.client.ts:39 throws RESEND_WEBHOOK_SECRET is not configured if webhook signature verification is exercised. Resend inbound webhook verification is presently UNCONFIGURED (known gap, 2026-07-07). Closing it = follow the "new secret" checklist in section 4 for resend-webhook-secret.txt plus a backend compose mount.
3. Feature flags / tiers as config
There is no external flag service. Tier gating is code-as-config in backend/src/core/config/feature-tiers.ts:
TIER_LEVELS:free(0) < pro(1) < enterprise(2), hierarchical (higher tier inherits lower).FEATURE_TIERSregistry - 4 entries as of 2026-07-07, allminTier: 'pro':document.scanMaintenanceSchedule,vehicle.vinDecode,fuelLog.receiptScan,maintenance.receiptScan. Each hasnameandupgradePrompt.VEHICLE_LIMITS: free=2, pro=5, enterprise=null (unlimited), withcanAddVehicle()/getVehicleLimitConfig().- FAIL-OPEN:
canAccessFeature()returns TRUE for unknown feature keys (deliberate). A typo in a feature key silently un-gates the feature wherevercanAccessFeatureis called directly (e.g.features/documents/api/documents.controller.ts).
Two enforcement mechanisms, both returning 403 {error: 'TIER_REQUIRED', requiredTier, currentTier, featureName, upgradePrompt, ...}:
- Standalone middleware
requireTier('<featureKey>')fromcore/middleware/require-tier.ts- must come AFTERrequireAuthin the preHandler array; fails CLOSED (500) on unknown keys. - Fastify decorator
fastify.requireTier({minTier}|{featureKey})fromcore/plugins/tier-guard.plugin.ts- callsauthenticateitself; the featureKey path FAILS OPEN on unknown keys, exactly like directcanAccessFeaturecalls:canAccessFeaturereturns true for unregistered keys (tier-guard.plugin.ts:70), so a typo'd or unregistered featureKey silently un-gates the route for ALL tiers. Theconfig?.minTier || 'pro'fallback (line 73) only shapes the 403 denial payload and is never reached for unknown keys. Only the standalone middleware (mechanism 1) fails closed on unknown keys.
Live examples: features/ocr/api/ocr.routes.ts lines 29/35/41 and features/vehicles/api/vehicles.routes.ts line 80.
Frontend contract: GET /api/config/feature-tiers (public, no auth - core/config/config.routes.ts) returns {tiers: TIER_LEVELS, features: FEATURE_TIERS}. Consumed by frontend/src/core/hooks/useTierAccess.ts (react-query key feature-tiers); denial UX via frontend/src/shared-minimal/components/UpgradeRequiredDialog.tsx; vehicle limits via frontend/src/features/vehicles/hooks/useVehicleLimitCheck.ts. The user's own tier comes from their profile (subscription_tier), not this endpoint.
4. How to add a configuration axis
Reminder: there is no working full local loop for most of these (missing local secrets). End-to-end proof happens on STAGING via the PR pipeline. CI runs zero tests/lint - the only gate is that images build and staging boots healthy - so run npm run type-check and relevant tests yourself in backend/ / frontend/ before pushing (see mvp-validation-and-qa).
A. New YAML config field (non-sensitive backend config)
- Add the field to
configSchemainbackend/src/core/config/config-loader.ts(give it.optional().default(...)unless you can update every environment's YAML in the same PR - a required field missing from the mounted YAML crashes the backend at import time). - Add the value to
config/app/production.yml(this one committed file serves all environments; there are no per-env YAML variants). - No compose change needed -
config/app/production.ymlis already mounted. Consume viaappConfig.config.<section>.<field>. - Deploy note: config reaches servers via the workflows' rsync of
config/; backend containers read it at start, so the normal deploy (which recreates backend) picks it up.
B. New secret
- Create
secrets/app/<name>.txtlocally with the real value AND a committed<name>.txt.examplesibling with a placeholder. - If the backend reads it: add the filename to the
secretFilesarray inloadSecrets()AND a snake_case key tosecretsSchemainconfig-loader.ts(use.optional()only if genuinely optional - remember the resend-webhook-secret gap started life as "optional"). - Mount it in EVERY compose file that runs the consuming service: base
docker-compose.ymland, for backend/frontend, the YAML-anchored blocks indocker-compose.blue-green.yml(prod runs blue/green services, not the base ones). - Add the Gitea Actions secret (repo settings) and an
inject_secret "VAR_NAME" "<name>.txt"entry plus SECRET_FILES entry inscripts/inject-secrets.sh- otherwise the next deploy will not create the file on the server and Docker will manufacture the directory trap. - Verify on staging: deploy, then
docker exec mvp-backend-staging ls -l /run/secrets/(names only - never cat values into logs or PRs).
C. New env var
- Set it in the consuming service's
environment:block indocker-compose.yml(and the anchored env indocker-compose.blue-green.ymlfor backend/frontend), with a sane${VAR:-default}. - Read it in exactly one place in the consumer (backend: prefer YAML config unless it is genuinely deploy-varying like the Stripe price IDs; ocr:
ocr/app/config.py). - If the value differs per environment, add it to the
.envgeneration step in.gitea/workflows/staging.yamlandproduction.yaml(Gitea Actions variable) and document it in.env.example. - Frontend
VITE_*: add a composebuild.argsentry AND--build-arglines in the staging workflow build step - and remember it is baked at build time.
D. New tier-gated feature
- Add the entry to
FEATURE_TIERSinbackend/src/core/config/feature-tiers.ts(key, minTier, name, upgradePrompt). - Guard the route:
preHandler: [requireAuth, requireTier('<key>')]orfastify.requireTier({featureKey: '<key>'}). Copy the exact key string - fail-open means a typo disables the gate silently. Add a test inbackend/src/core/config/tests/feature-tiers.test.tsstyle. - Frontend: gate the UI with
useTierAccessand route denials toUpgradeRequiredDialog. The feature list arrives automatically viaGET /api/config/feature-tiers- no frontend registry to update. - Validate on BOTH mobile and desktop (project hard requirement).
E. New Stripe price
- Add the env var to the backend
environment:block indocker-compose.ymlAND the blue-green anchor, and to.env.example. - Wire it into BOTH maps in
backend/src/features/subscriptions/domain/subscriptions.service.ts: the plan-name-to-env-var map (~line 833) and the reverse priceId-to-tier inference (~line 885). Missing the reverse map means webhooks classify the subscription as the wrong tier. - Add the Gitea Actions VARIABLE (not secret - price IDs are config) and echo it into
.envin both deploy workflows' env-generation step. - The publishable key side (
VITE_STRIPE_PUBLISHABLE_KEY) is build-time frontend config - see C.4.
Provenance and maintenance
Authored 2026-07-07 from direct inspection of the repo at commit e729d42 era. Volatile facts and how to re-verify each (run from repo root):
| Fact (as of 2026-07-07) | Re-verify with |
|---|---|
| Backend env-var catalog (and DATABASE_HOST/REDIS_HOST still dead) | `grep -rnoE "process.env(.[A-Z_]+ |
| CONFIG_PATH / SECRETS_DIR defaults | grep -n "CONFIG_PATH|SECRETS_DIR" backend/src/core/config/config-loader.ts |
| The 9 backend-loaded secret names and which are optional | sed -n '113,190p' backend/src/core/config/config-loader.ts |
| resend-webhook-secret still unconfigured | grep -rn "resend-webhook" docker-compose*.yml scripts/inject-secrets.sh; ls secrets/app/ | grep resend |
| inject-secrets file list (12) and required env vars | grep -n "inject_secret |SECRET_FILES" scripts/inject-secrets.sh |
| Compose secret mounts per service | grep -n "/run/secrets" docker-compose.yml docker-compose.blue-green.yml |
| Secrets committed to git (should be .example + google-wif-config.json only) | git ls-files secrets/ |
| Local directory-trap state | ls -la secrets/app/ | grep ^d |
| FEATURE_TIERS entries and VEHICLE_LIMITS (free 2 / pro 5 / enterprise unlimited) | grep -n "minTier|VEHICLE_LIMITS" -A3 backend/src/core/config/feature-tiers.ts |
| Fail-open on unknown feature keys | sed -n '57,64p' backend/src/core/config/feature-tiers.ts |
| feature-tiers endpoint shape | cat backend/src/core/config/config.routes.ts |
| requireTier usage sites | grep -rn "requireTier" backend/src/features --include="*.ts" | grep -v test |
| Stripe price env vars in service maps | grep -n "STRIPE_.*PRICE_ID" backend/src/features/subscriptions/domain/subscriptions.service.ts |
| Frontend VITE_* consumption | grep -rnoE "import\.meta\.env\.[A-Z_]+" frontend/src | sort -u |
| Frontend runtime Maps-key injection | cat frontend/scripts/load-config.sh; grep -n "load-config" frontend/Dockerfile |
| OCR env catalog and compose overrides | cat ocr/app/config.py; sed -n '/mvp-ocr:/,/mvp-postgres:/p' docker-compose.yml |
| config/app/ci.yml and config/shared/production.yml still unreferenced/unread | grep -rn "ci\.yml" backend/ scripts/ .gitea/ docker-compose*.yml Makefile; grep -rn "shared.yml" backend/src (grep's --include does NOT expand brace globs like *.{ts,sh} - it silently matches nothing) |
| production.yml.example still stale vs schema | diff <(grep -oE "^[a-z_]+:" config/app/production.yml) <(grep -oE "^[a-z_]+:" config/app/production.yml.example) |
| Workflow .env generation and wif-config copy | grep -n "generate-log-config|inject-secrets|google-wif" .gitea/workflows/staging.yaml .gitea/workflows/production.yaml |
| auth0-ocr secrets still mounted-but-unread | grep -rni "auth0" ocr/app --include="*.py" |