← Galaxy / notesorg-wide / engineering-claude

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 via zod-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_id array). 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 push is FORBIDDEN in any environment except a one-off local prototype. Always drizzle-kit generate + drizzle-kit migrate. CI runs drizzle-kit check and 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_partman handles new-partition + detach-old-partition rotation. Never insert into the parent table directly; let the trigger route.
  • Persian FTS uses the persian_hunspell text search configuration with unaccent in the pipeline. Don't fall back to simple without explicit reason in the PR description.
  • pgvector embeddings use halfvec (not vector) for 1536-dim insight embeddings — ~2× memory savings with negligible recall loss. HNSW index params: m=16, ef_construction=64. Always CREATE INDEX CONCURRENTLY in any non-local environment.
  • Every per-domain table has a business_id FK. No exceptions.
  • Drizzle Kit 1.0+ regression: ALTER DROP COLUMN / RENAME COLUMN execute without the confirmation prompt 0.x had. Always read the generated .sql before migrate.

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_businesses and 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.stringify field order.
  • Verifier cadence: hourly walk over the last 24h + daily full-chain walk + a CI test that a deliberately tampered row gets detected.
  • mcp_app role has NO update/delete on audit_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 via COMMIT; DROP SCHEMA public CASCADE;).
  • Transport: Streamable HTTP per the 2026-07-28 RC (stateless). Set required Mcp-Method + Mcp-Name request headers so load balancers can route without parsing the body.
  • Postgres role: mcp_app with SELECT/INSERT/UPDATE on per-domain tables — NO DROP, NO DDL, NO DELETE, NO write on audit_log except via the helper function.
  • Tools must be narrow (one operation each). NO sql_query tool. NO bulk writes from MCP.
  • Every write tool declares destructiveHint: true, requires confirm: literal(true) as a parameter, and accepts an optional dryRun: boolean that returns the would-be effect without executing. Use a single defineWriteTool(...) helper so this is enforced uniformly.
  • Identity passthrough: validate the JWT aud claim (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-layer WHERE business_id IN (...) filter.
  • Prompt-injection structural defense: every tool response splits into trustedSummary (enums, counters, structural data we own) and untrustedData (user-generated text fenced inside <untrusted>…</untrusted>). Never interpolate user text into summary fields. Treat every comments.text, notes.content_md, pm_tasks.task, audit_issues.problem etc. as untrusted.
  • Every write writes an audit_log row with source='mcp' + the identified user's ID.

10. galaxy-docs rules

  • Markdown body is plain text + standard CommonMark. YAML frontmatter is parsed by gray-matter with round-trip preservation (don't reorder keys).
  • Wikilinks [[Slug]] and aliased [[Slug|Display]] are parsed via remark-wiki-link at write-time and stored in entity_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>.md with 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.
  • 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_public and password_hash are mutually exclusive — enforced by a DB CHECK constraint, not just app logic.
  • noindex,nofollow meta + X-Robots-Tag header on every /s/* route by default.
  • OpenGraph meta rendered only when is_public_public = true AND password_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 .env files (only .env.example).
  • The leaked credentials in the legacy apps (pms1405 from 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.yaml files SOPS-encrypted with age, committed to the repo, decrypted at deploy time into Compose secrets: file mounts. env_file: is forbidden in production Compose — it leaks into docker inspect.
  • Local dev secrets live in docker-compose.override.yml (gitignored) — that file may use environment: 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 && up in prod.
  • Auto-revert on smoke failure swaps APP_SHA back to PROD_PREV_SHA and re-ups, but with: 15-min loop guard, REVERT_LOCK flag 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.json is necessary but no longer sufficient — Shai-Hulud showed that valid SLSA-3 provenance can hide hijacked pipelines.
  • Layered protection:
    1. Socket MCP in .mcp.json so Claude sees dependency risk during the install decision.
    2. pnpm config: minimumReleaseAge = '7d' (delay adoption of just-published packages — the slopsquat / supply-chain attack window).
    3. --ignore-scripts is the hard default; explicit --allow-scripts=<pkg> required to opt a single package in.
    4. CI step: node scripts/check-package-allowlist.mjs (already present); plus pnpm audit --audit-level=high blocks merge.

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 check passes
  • 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 *_fa column, jsonb column, per-business table, or API route was added, verify the corresponding rubric covers it (see scripts/eval/_eval-utils.ts BILINGUAL_COLUMNS and PER_DOMAIN_TABLES_WITH_BUSINESS_ID).

17. When to ask the human

  • Schema changes that touch audit_log or entity_links or share_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-add entry 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 --force to any branch
  • drizzle-kit push in any environment
  • Direct SQL DROP / TRUNCATE / DELETE FROM (always go through Drizzle migrations)
  • Writing or reading .env files in the repo (production secrets live only on the VPS)
  • Editing .github/workflows/deploy-prod.yml without explicit human approval
  • Installing npm packages not in packages-allowlist.json
  • Running with --no-verify or --ignore-scripts removed
  • 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:

  1. /simplify has been run on the staged diff (3 review agents: reuse, quality, efficiency). Apply priority fixes inline. Defer bigger refactors to docs/operations/SIMPLIFY-BACKLOG.md.
  2. pnpm test has passed (or the narrower pnpm --filter <pkg> test if 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/

Outbound links (0)

This note doesn't reference any other entity.

Version history (1)

  • v12026-06-01 10:19"galaxy-docs importer: initial import"