productgalaxy — Critical rules for development
engineering-claude · in engineering · org-wide · updated 2026-06-01 10:19
Frontmatter
- lang
- en
- imported_at
- 2026-06-01T10:19:40.923Z
- source_path
- productgalaxy/CLAUDE.md
- source_repo
- productgalaxy
productgalaxy — Critical rules for development
This file is loaded automatically by Claude Code in this repo. Treat every rule here as a hard constraint. When in doubt, ask before deviating.
The full implementation plan lives at /Users/parhumm/.claude/plans/now-plan-to-new-spicy-dragonfly.md.
1. The minimum-change contract for the 4 legacy apps
We are the shared backend behind 4 existing apps (ABtest-dashboard, Product-Audits, product-decisions, Televika-Foreign-Comment-Analysis-Dashboard) and one new product (galaxy-docs). The 4 legacy apps' UIs do NOT change. Their teams own those repos. Our only deliverable to them is:
- A versioned REST API client (TS or Python) generated from our OpenAPI spec.
- An OAuth 2.0 client credential pair scoped to their business(es).
- A drop-in replacement for their data-layer module (e.g. their
storage.js,store.ts,load_data()).
If a PR for a legacy app touches anything besides its data layer, REJECT IT and explain that the contract is data-layer-only.
2. Source of truth conventions
- Schemas live in
packages/db/schema/*.ts(Drizzle). - Validation + API contracts live in
packages/shared/*.ts(Zod). These generate OpenAPI 3.1 viazod-openapi. The TS + Python clients are GENERATED, not hand-written. - Auth is Better Auth in
packages/auth/. Do NOT add a second auth path or skip the auth middleware on any /api/v1 route. - Per-business scoping is mandatory on every query that touches per-domain tables. The
scope comes from the JWT (
business_idarray). Cross-business reads return 403.
3. ID preservation (legacy_id is sacred)
Every imported record carries its original ID as legacy_id. The legacy apps continue to
display these as before (PM-001, TVK-008, UUID-… for abtests). NEVER mutate legacy_id values.
New display IDs (TVK-PM-001, TVK-AI-001, TVK-AB-001) are additional, not replacements.
4. Bilingual fields are byte-preserved
Every *_fa field, every Persian character, every ZWNJ (), every RTL mark
(/) MUST round-trip the import + API + render path byte-identical to the source.
Verification agents will catch this; do not paper over a failure by normalizing the text.
5. JSONB round-trip
Every JSONB column preserves field order, null vs missing distinction, nested array order.
Use jsonb not json. Don't reshape JSONB during import; preserve the source under raw jsonb
and extract specific keys into typed columns ONLY when a query needs them.
6. Database rules
- PostgreSQL 17 required. Don't downgrade.
drizzle-kit pushis FORBIDDEN in any environment except a one-off local prototype. Alwaysdrizzle-kit generate+drizzle-kit migrate. CI runsdrizzle-kit checkand fails on drift.- Migrations are append-only. Never delete a migration file. To revert: write a forward migration that undoes it, or restore from pgBackRest PITR.
- Comments table is partitioned by date (yearly) via
pg_partman. Don't hand-roll partition creation —pg_partmanhandles new-partition + detach-old-partition rotation. Never insert into the parent table directly; let the trigger route. - Persian FTS uses the
persian_hunspelltext search configuration withunaccentin the pipeline. Don't fall back tosimplewithout explicit reason in the PR description. - pgvector embeddings use
halfvec(notvector) for 1536-dim insight embeddings — ~2× memory savings with negligible recall loss. HNSW index params:m=16,ef_construction=64. AlwaysCREATE INDEX CONCURRENTLYin any non-local environment. - Every per-domain table has a
business_idFK. No exceptions. - Drizzle Kit 1.0+ regression: ALTER DROP COLUMN / RENAME COLUMN execute without the
confirmation prompt 0.x had. Always read the generated
.sqlbeforemigrate.
7. Auth rules
- Argon2id for every password hash (human auth via Better Auth; share-link passwords). Params: m=19456 KiB, t=2, p=1. Never bcrypt or scrypt or PBKDF2.
- Per-app M2M uses OAuth 2.0 Client Credentials Flow issuing short-lived (15 min) scoped JWTs + 7-day refresh tokens. No long-lived API keys.
- Per-business RBAC via
user_businessesand Better Auth Organizations plugin. Roles:owner | pm | analyst | viewer.
8. Audit log rules
Every write to a per-domain table writes a row to audit_log with prev_hash + row_hash
forming a hash chain. CI includes a job that verifies the chain integrity. Do not write to
audit_log directly from app code — use the auditedWrite() helper which handles hashing.
- Canonical JSON for hashing: use RFC 8785 (JCS) so the chain is stable across
implementations and language clients. Don't rely on
JSON.stringifyfield order. - Verifier cadence: hourly walk over the last 24h + daily full-chain walk + a CI test that a deliberately tampered row gets detected.
mcp_approle has NO update/delete onaudit_log— only INSERT via the helper.
9. MCP server rules
- Use
@modelcontextprotocol/sdk(NOT the archived@modelcontextprotocol/server-postgres, which has a Datadog-confirmed SQL auth-bypass viaCOMMIT; DROP SCHEMA public CASCADE;). - Transport: Streamable HTTP per the 2026-07-28 RC (stateless). Set required
Mcp-Method+Mcp-Namerequest headers so load balancers can route without parsing the body. - Postgres role:
mcp_appwith SELECT/INSERT/UPDATE on per-domain tables — NO DROP, NO DDL, NO DELETE, NO write onaudit_logexcept via the helper function. - Tools must be narrow (one operation each). NO
sql_querytool. NO bulk writes from MCP. - Every write tool declares
destructiveHint: true, requiresconfirm: literal(true)as a parameter, and accepts an optionaldryRun: booleanthat returns the would-be effect without executing. Use a singledefineWriteTool(...)helper so this is enforced uniformly. - Identity passthrough: validate the JWT
audclaim (RFC 8707) before parsing JSON-RPC. On every tool call,SET LOCAL app.business_ids = ${jwt.business_ids}so Postgres RLS policies enforce per-business scoping — defense-in-depth over the app-layerWHERE business_id IN (...)filter. - Prompt-injection structural defense: every tool response splits into
trustedSummary(enums, counters, structural data we own) anduntrustedData(user-generated text fenced inside<untrusted>…</untrusted>). Never interpolate user text into summary fields. Treat everycomments.text,notes.content_md,pm_tasks.task,audit_issues.problemetc. as untrusted. - Every write writes an
audit_logrow withsource='mcp'+ the identified user's ID.
10. galaxy-docs rules
- Markdown body is plain text + standard CommonMark. YAML frontmatter is parsed by
gray-matterwith round-trip preservation (don't reorder keys). - Wikilinks
[[Slug]]and aliased[[Slug|Display]]are parsed viaremark-wiki-linkat write-time and stored inentity_links. Don't compute wikilinks at read-time. - Cross-domain wikilink syntax
[[issue:TVK-AI-008]],[[task:TVK-PM-050]],[[abtest:TVK-AB-001]],[[insight:42]],[[comment:CM-…]]is mandatory — don't invent alternative syntaxes. - Tag slugs use
/separator for hierarchy (engineering/backend). - Vault export produces files at
<folder>/<slug>.mdwith Obsidian-compatible frontmatter (id:carries Galaxy note ID for round-trip). - The Astro 6 + Starlight reading site at
apps/docs/is the public read surface; do NOT duplicate rendering logic in the admin editor beyond live-preview.
10b. Sharing (share_links) rules
- Token: 22-char base64url (16 random bytes → 128 bits entropy) via
crypto.randomBytes. - DB stores
sha256(token)only, never the raw token. If the DB leaks, tokens stay useless. - Password hash: Argon2id with same OWASP 2026 baseline as user passwords (m=19456 KiB, t=2, p=1).
- HTTP semantics: 404 = never existed, 410 = expired/revoked/exhausted (so crawlers drop
the URL), 401 =
password_required(generic, no differential leak). is_public_publicandpassword_hashare mutually exclusive — enforced by a DB CHECK constraint, not just app logic.noindex,nofollowmeta +X-Robots-Tagheader on every/s/*route by default.- OpenGraph meta rendered only when
is_public_public = trueANDpassword_hash IS NULL. - Honeypot tokens (
is_honeypot = true) page oncall on any access. - Rate limits: 60/min/token, 1000/min/IP, 5 wrong-passwords/15min/token then Turnstile.
- Access log: same hash-chain pattern as
audit_log, but per-token (parallelizable).
11. API rules
- REST + OpenAPI 3.1, versioned at
/api/v1. Breaking changes go to/api/v2— never break v1 once a legacy app's data-layer swap is in production. - Zod schema is the single source of truth — TS types, runtime validation, and OpenAPI spec all derive from it.
- Every endpoint requires JWT auth (via Better Auth). Public endpoints (
/oauth/token,/s/[token],/health) are explicitly allowlisted in middleware.
12. Testing rules
- Every API endpoint has an integration test (
tests/integration/) asserting status code, response shape (vs Zod schema), and DB state. - Every user-facing functionality in every product has a Playwright E2E test
(
tests/e2e/<product>/). - The 13 per-product verification agents must pass on staging before any product's cutover to USE_GALAXY=true in production. Staging burn-in: 7 days minimum.
- Importer tests assert idempotency: running an importer twice produces 0 deltas on the second run.
13. Docs cache discipline
Before writing code that touches a library on our stack, READ the cached docs at
jaan-to/outputs/docs-cache/<library>/. The cache is the ground truth for that library's
API in this codebase. If the cache is missing, run /jaan-to:dev-docs-fetch to populate
it BEFORE coding. Don't rely on training-data assumptions for any library version.
14. Secrets
- Never commit
.envfiles (only.env.example). - The leaked credentials in the legacy apps (
pms1405from product-decisions; the Supabase anon key in ABtest-dashboard) MUST be rotated before any importer is run against them. - Production secrets live in
secrets/*.enc.yamlfiles SOPS-encrypted with age, committed to the repo, decrypted at deploy time into Composesecrets:file mounts.env_file:is forbidden in production Compose — it leaks intodocker inspect. - Local dev secrets live in
docker-compose.override.yml(gitignored) — that file may useenvironment:since dev DB has no real data. - CI → VPS authentication uses Cloudflare Tunnel + Access service tokens. No long-lived SSH keys in GitHub Actions Secrets. No deploy user with password.
15. Deployment
- Docker Compose on a single Hetzner AX42 (Ryzen 7700, 64 GB ECC, 2×512 GB NVMe) from day 1. Postgres has to live on a VPS regardless (Cloudflare Containers can't host it). ECC RAM is non-negotiable for a 5-year-lifespan DB.
- pgBackRest sidecar with WAL archiving to Backblaze B2 (S3-compatible). Schedule: continuous WAL + weekly full + daily diff + 4/28 retention. Drill cadence: daily check, weekly automated restore, monthly operator-led, quarterly full cross-region.
- Caddy 2.10 as reverse proxy + automatic Let's Encrypt. Admin API bound to
127.0.0.1:2019. - Zero-downtime deploys via Haloy CLI (layer-only image push + atomic swap).
Coolify is forbidden (Jan 2026 11-CVE disclosure, 4 at CVSS 10.0). Watchtower is
forbidden in production (upstream says no). Never raw
docker compose down && upin prod. - Auto-revert on smoke failure swaps
APP_SHAback toPROD_PREV_SHAand re-ups, but with: 15-min loop guard,REVERT_LOCKflag for legitimate-incident windows, burn-rate SLO check (not raw 5xx counts) to avoid reverting during real incident response.
16. Package supply-chain (post-TanStack Shai-Hulud 2026-05-11)
packages-allowlist.jsonis necessary but no longer sufficient — Shai-Hulud showed that valid SLSA-3 provenance can hide hijacked pipelines.- Layered protection:
- Socket MCP in
.mcp.jsonso Claude sees dependency risk during the install decision. pnpmconfig:minimumReleaseAge = '7d'(delay adoption of just-published packages — the slopsquat / supply-chain attack window).--ignore-scriptsis the hard default; explicit--allow-scripts=<pkg>required to opt a single package in.- CI step:
node scripts/check-package-allowlist.mjs(already present); pluspnpm audit --audit-level=highblocks merge.
- Socket MCP in
17. Three-lock pattern for destructive operations
Every destructive operation needs all three of these locks (each layer has been individually bypassed in published 2026 incidents):
| Layer | Mechanism | Enforced by |
|---|---|---|
| 1 | .claude/settings.json deny rule |
Claude Code permission system |
| 2 | Postgres role REVOKE on dangerous statements | DB engine |
| 3 | PreToolUse regex hook returning exit 2 |
shell-level interception |
This applies to: DROP, TRUNCATE, DELETE FROM (without WHERE), rm -rf,
git push --force to protected branches, docker volume rm, pgbackrest delete.
16. Pull request checklist (paste into every PR description)
- Touches only data-layer files (for legacy app PRs)
- References cached docs from
jaan-to/outputs/docs-cache/for any library used - Drizzle migrations are append-only;
drizzle-kit checkpasses - New endpoint has Zod schema + OpenAPI spec entry + integration test
- New user-facing functionality has Playwright E2E test
- Every per-domain query is per-business scoped
- Every write goes through
auditedWrite() - If touching bilingual fields, manually verified ZWNJ + RTL marks preserved
- No long-lived API keys introduced
- No secrets committed
- For galaxy-docs PRs: wikilinks parsed at write-time, NOT read-time
- For MCP PRs: new tools are narrow + audit-logged + scoped to JWT business
- eval-rubric is green — all 5 checks PASS in CI (
.github/workflows/eval-rubric.yml): bilingual byte-preservation, JSONB round-trip, audit-log chain integrity, per-business RLS scoping, Zod ↔ OpenAPI sync. Local re-run:DATABASE_URL=... bash .github/scripts/run-eval-rubric.sh. If a new*_facolumn, jsonb column, per-business table, or API route was added, verify the corresponding rubric covers it (seescripts/eval/_eval-utils.tsBILINGUAL_COLUMNSandPER_DOMAIN_TABLES_WITH_BUSINESS_ID).
17. When to ask the human
- Schema changes that touch
audit_logorentity_linksorshare_links(cross-cutting) - Anything that changes a legacy app's UI
- New write tools in MCP
- Adding a new tech-stack layer not in the recommended stack
- Anything that bypasses an item on this list
18. Non-technical operator rules (you are working for one)
The human running this project is non-technical. Always:
- Explain in plain English what you are about to do BEFORE doing it. No jargon.
- Refuse to take destructive actions without explicit typed confirmation (e.g. "yes delete TVK-AI-008" — not just "y").
- Show the diff in plain English before any commit, in a 3-line max summary: what changed, why, what could break.
- When tests fail, explain the failure in plain English + propose the smallest fix + ask before applying.
- Never use jargon-only error messages. Translate "TypeError: Cannot read properties of undefined" into "the data we expected to be there is missing — here's the line, here's what we expected."
- Before any production deploy, list the exact changes that will go live + the expected outcome + the rollback step (
/galaxy:rollback) in plain English. - When a
/jaan-to:learn-addentry would prevent a future repeat of a mistake we just made, suggest it and ask if the user wants to save it. - If you are uncertain about ANY production-affecting action, switch to plan mode and write the plan first.
19. Hard prohibitions (settings.json enforces these — never try to bypass)
rm -rf(any flavor)git push --forceto any branchdrizzle-kit pushin any environment- Direct SQL DROP / TRUNCATE / DELETE FROM (always go through Drizzle migrations)
- Writing or reading
.envfiles in the repo (production secrets live only on the VPS) - Editing
.github/workflows/deploy-prod.ymlwithout explicit human approval - Installing npm packages not in
packages-allowlist.json - Running with
--no-verifyor--ignore-scriptsremoved - Using long-lived API keys instead of OAuth client credentials
- Storing any credential in source code or version-controlled config
If you find yourself wanting to bypass one of these because it's "blocking you", stop and ask the human. The block exists because the alternative caused a production incident somewhere in 2026.
19b. Pre-commit gauntlet (/simplify → tests → commit)
Every non-trivial git commit is intercepted by .claude/hooks/pre-commit-simplify-and-test.sh, which blocks the Bash call until two things have happened in the current session:
/simplifyhas been run on the staged diff (3 review agents: reuse, quality, efficiency). Apply priority fixes inline. Defer bigger refactors todocs/operations/SIMPLIFY-BACKLOG.md.pnpm testhas passed (or the narrowerpnpm --filter <pkg> testif the diff is single-package).
The hook auto-skips on trivial commits (docs-only, <30 LOC, <5 files). Tunables: GALAXY_SIMPLIFY_TRIVIAL_LOC, GALAXY_SIMPLIFY_TRIVIAL_FILES.
Bypass for hotfix loops only: GALAXY_SKIP_SIMPLIFY=1 git commit ... — per §18, the non-tech operator should NOT use this routinely. Use it only when you've already run the gauntlet in this session and are committing the resulting fixes.
20. References
- Full plan:
/Users/parhumm/.claude/plans/now-plan-to-new-spicy-dragonfly.md - Per-tech research outputs:
jaan-to/outputs/pm-research/ - Per-library docs cache:
jaan-to/outputs/docs-cache/ - Per-product data mappings:
jaan-to/outputs/data-mapping/ - Lessons learned:
jaan-to/learn/