Files
motovaultpro/.claude/skills/mvp-config-and-secrets/SKILL.md
T
Eric GullicksonandClaude Fable 5 c239bb9347
Deploy to Staging / Build Images (push) Successful in 5m32s
Deploy to Staging / Deploy to Staging (push) Successful in 44s
Deploy to Staging / Verify Staging (push) Successful in 5s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
chore: replace AI skill library with 16 verified mvp-* skills
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]>
2026-07-09 20:21:42 -05:00

201 lines
22 KiB
Markdown

---
name: mvp-config-and-secrets
description: 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:
1. A YAML config file from `CONFIG_PATH` (default `/app/config/production.yml`), validated by a Zod schema (`configSchema`). The file is the committed, non-sensitive `config/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.
2. File-based secrets from `SECRETS_DIR` (default `/run/secrets`), validated by `secretsSchema`. Each secret is one file, name matching the compose mount (no `.txt` inside 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; copy `production.yml` itself.
- `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.yml` by 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.txt` are 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.txt` and `google-maps-map-id.txt` have no `.example` sibling.
### 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_TIERS` registry - 4 entries as of 2026-07-07, all `minTier: 'pro'`: `document.scanMaintenanceSchedule`, `vehicle.vinDecode`, `fuelLog.receiptScan`, `maintenance.receiptScan`. Each has `name` and `upgradePrompt`.
- `VEHICLE_LIMITS`: free=2, pro=5, enterprise=null (unlimited), with `canAddVehicle()` / `getVehicleLimitConfig()`.
- FAIL-OPEN: `canAccessFeature()` returns TRUE for unknown feature keys (deliberate). A typo in a feature key silently un-gates the feature wherever `canAccessFeature` is called directly (e.g. `features/documents/api/documents.controller.ts`).
Two enforcement mechanisms, both returning 403 `{error: 'TIER_REQUIRED', requiredTier, currentTier, featureName, upgradePrompt, ...}`:
1. Standalone middleware `requireTier('<featureKey>')` from `core/middleware/require-tier.ts` - must come AFTER `requireAuth` in the preHandler array; fails CLOSED (500) on unknown keys.
2. Fastify decorator `fastify.requireTier({minTier}|{featureKey})` from `core/plugins/tier-guard.plugin.ts` - calls `authenticate` itself; the featureKey path FAILS OPEN on unknown keys, exactly like direct `canAccessFeature` calls: `canAccessFeature` returns true for unregistered keys (tier-guard.plugin.ts:70), so a typo'd or unregistered featureKey silently un-gates the route for ALL tiers. The `config?.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)
1. Add the field to `configSchema` in `backend/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).
2. Add the value to `config/app/production.yml` (this one committed file serves all environments; there are no per-env YAML variants).
3. No compose change needed - `config/app/production.yml` is already mounted. Consume via `appConfig.config.<section>.<field>`.
4. 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
1. Create `secrets/app/<name>.txt` locally with the real value AND a committed `<name>.txt.example` sibling with a placeholder.
2. If the backend reads it: add the filename to the `secretFiles` array in `loadSecrets()` AND a snake_case key to `secretsSchema` in `config-loader.ts` (use `.optional()` only if genuinely optional - remember the resend-webhook-secret gap started life as "optional").
3. Mount it in EVERY compose file that runs the consuming service: base `docker-compose.yml` and, for backend/frontend, the YAML-anchored blocks in `docker-compose.blue-green.yml` (prod runs blue/green services, not the base ones).
4. Add the Gitea Actions secret (repo settings) and an `inject_secret "VAR_NAME" "<name>.txt"` entry plus SECRET_FILES entry in `scripts/inject-secrets.sh` - otherwise the next deploy will not create the file on the server and Docker will manufacture the directory trap.
5. 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
1. Set it in the consuming service's `environment:` block in `docker-compose.yml` (and the anchored env in `docker-compose.blue-green.yml` for backend/frontend), with a sane `${VAR:-default}`.
2. 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`).
3. If the value differs per environment, add it to the `.env` generation step in `.gitea/workflows/staging.yaml` and `production.yaml` (Gitea Actions variable) and document it in `.env.example`.
4. Frontend `VITE_*`: add a compose `build.args` entry AND `--build-arg` lines in the staging workflow build step - and remember it is baked at build time.
### D. New tier-gated feature
1. Add the entry to `FEATURE_TIERS` in `backend/src/core/config/feature-tiers.ts` (key, minTier, name, upgradePrompt).
2. Guard the route: `preHandler: [requireAuth, requireTier('<key>')]` or `fastify.requireTier({featureKey: '<key>'})`. Copy the exact key string - fail-open means a typo disables the gate silently. Add a test in `backend/src/core/config/tests/feature-tiers.test.ts` style.
3. Frontend: gate the UI with `useTierAccess` and route denials to `UpgradeRequiredDialog`. The feature list arrives automatically via `GET /api/config/feature-tiers` - no frontend registry to update.
4. Validate on BOTH mobile and desktop (project hard requirement).
### E. New Stripe price
1. Add the env var to the backend `environment:` block in `docker-compose.yml` AND the blue-green anchor, and to `.env.example`.
2. 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.
3. Add the Gitea Actions VARIABLE (not secret - price IDs are config) and echo it into `.env` in both deploy workflows' env-generation step.
4. 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_]+|\['[A-Z_]+'\])" backend/src --include="*.ts" \| grep -v ".test." \| sort -u` |
| 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"` |