Every repeated mistake or non-obvious project-specific trap must be documented here. Entries must be factual, actionable, and tied to root cause.
### YYYY-MM-DD — Short title
- Area:
- Symptom:
- Root cause:
- Fix:
- Prevention rule:
- Related files:
- Related tests:2026-05-15 — Lead was excluded from GDPR portability export because "pre-contact" was treated as "not personal data"
- Area: gdpr / legal
- Symptom:
/gdpr/contacts/:id/exportand/gdpr/clients/:id/exportshipped from S17, but there was no Lead equivalent. The mental model was "Leads are pre-contact, they don't have personal data yet" — completely wrong. - Root cause: Art. 20 of the GDPR grants portability for any personal data the controller holds about the data subject, regardless of relationship stage. A Lead row with firstName/lastName/email/phone IS personal data the moment it lands. We confused our funnel stage (pre-customer) with the legal status of the data (already personal). Same trap is easy to fall into for any "intent" or "waitlist" table.
- Fix: added
GdprService.exportLead,GdprService.eraseLead(closing Art. 17 in parallel),buildLeadAnonymisationPatch, and the matching/gdpr/leads/:id/export+DELETE /gdpr/leads/:idcontroller routes. Export scope: Lead row + LeadScore rows (entityType='LEAD') + converted Contact row ifconvertedToContactIdis set. Multi-tenant isolation pinned by unit test assertingrunWithTenant(tenantId, ...)uses the REQUEST tenant from ALS, not the row's stored tenantId or a constant. Coverage matrix added todocs/DATA_CLASSIFICATION.md. Closed in commitabff980. - Prevention rule: before excluding ANY entity from GDPR export, ask "does this row contain or correlate to data about a natural person?". If yes, it's in scope — even if the relationship is pre-customer, expired, or anonymous-by-intent. When adding a new entity to schema.prisma, the PR template should force answering this question. Add to the GDPR coverage matrix in
docs/DATA_CLASSIFICATION.mdat the same time. - Related files:
apps/api/src/modules/gdpr/gdpr.service.ts,apps/api/src/modules/gdpr/gdpr.controller.ts,docs/DATA_CLASSIFICATION.md,apps/ai-worker/tests/test_redaction.py(new — pins Presidio redaction contract Art. 32). - Related tests:
apps/api/src/modules/gdpr/gdpr.service.spec.ts(multi-tenant isolation test on exportLead is the load-bearing one),apps/api/src/modules/gdpr/gdpr.helpers.spec.ts,apps/ai-worker/tests/test_redaction.py.
- Area: prisma / tooling
- Symptom: added
defaultCallScript Json?toTenantmodel, applied migration to DB directly, butpnpm typecheckfailed withProperty 'defaultCallScript' does not exist on type '{...}'. Wasted a few minutes assuming the schema edit was insufficient. - Root cause: Prisma generates TypeScript types from
schema.prismaintonode_modules/.pnpm/.../@prisma/client. Editingschema.prismadoesn't regenerate the client;prisma generatedoes. CI catches this because Prisma generate runs in the CI pipeline before typecheck; locally it has to be done manually. - Fix:
pnpm --filter @amass/api exec prisma generateafter everyschema.prismaedit, before running typecheck. Add this to your local "after schema change" checklist alongside writing the migration SQL. - Prevention rule: any schema.prisma diff must be paired with a prisma generate in the same commit boundary. CI has it; treat local typecheck failures with "Property X does not exist" as a "did you regenerate?" hint, not a code bug.
- Related files:
apps/api/prisma/schema.prisma,apps/api/package.json(prisma:generatescript).
2026-05-14 — Web build fails on ES2020 destructuring after esbuild 0.27 override; bump target to es2022
- Area: web build / esbuild
- Symptom: pushed feat commit
6675c7d, CIlint-typecheck-buildstep failed only on@amass/webbuild witherror TS Transforming destructuring to the configured target environment ("chrome87", "edge88", "es2020", "firefox78", "safari14" + 2 overrides) is not supported yet. Specifically onuseInfiniteQuery's async-iterator destructuring in audit.page-*.js. - Root cause: pnpm overrides bumped
esbuildto>=0.25.0to close a security advisory. The new esbuild (0.27.7) no longer transforms certain destructuring patterns down to ES2020 — what was previously a polyfill is now a build error. - Fix:
vite.config.ts→build.target: 'es2022'. CRM is auth-gated B2B, all evergreen browsers from 2022+ support ES2022 natively, no polyfill needed. - Prevention rule: when bumping esbuild/vite via overrides for security advisories, always run
pnpm build(not just typecheck/test) locally before pushing. Test-only verification is insufficient for build-target compatibility issues. - Related files:
apps/web/vite.config.ts(addedtarget: 'es2022'),package.json(pnpm.overrides.esbuild).
2026-05-14 — Read AGENTS.md at session start, update control docs at session end — both were skipped today
- Area: process / agent discipline
- Symptom: ~5 hours of code changes in a single chat (Twilio real wiring, Whisper pipeline activation, pipeline end-to-end fixes, GestCom adapter rewrite, 4 P1 contract fixes, Docker bloat reduction) shipped with zero updates to
CHANGELOG.md,LESSONS.md,SECURITY_FINDINGS.md, orRELEASE_CHECKLIST.md. User had to ask, "ai mai umblat la control docs?" before I noticed. - Root cause: jumped straight to user requests without reading
AGENTS.mdat session start. The "Required documentation after every task" section never primed working memory, so no update reflex kicked in between tasks. - Fix: at the top of every new session, before any other action, read
AGENTS.mdfully (not skim) and explicitly state the doc-update obligation in the response. Treat the obligation as part of "task done" — a task isn't done until the relevant doc line is added. For long sessions, do a control-doc sweep every ~5 commits, not "at the end" (since "the end" never arrives without prompting). - Prevention rule:
AGENTS.mdmust be the firstReadof every fresh session, not searched-for or assumed. Same forLESSONS.mdso today's traps don't repeat. - Related files:
AGENTS.md(Required startup checklist + Required documentation after every task), thisLESSONS.mdentry.
2026-05-14 — Twilio Trial blocks adding new verified caller IDs — only existing verified numbers can be dialled
- Area: integrations / Twilio
- Symptom: tried to add
+40757970793as an outgoing caller ID so the demo could call a second RO number on Trial.POST /Accounts/{SID}/OutgoingCallerIds.jsonreturned400 code:10002 "Placing verification calls is not supported on trial accounts. Please upgrade to a full account first." - Root cause: Twilio Trial accounts cannot place outbound verification calls. The single pre-existing verified caller ID (
+40754070368in our case) is the only destination the Trial can dial. Trial = "demo with one phone you already own", not "demo with arbitrary contacts". - Fix: documented the limitation honestly and offered three paths: (1) stay on gratis but call only
+40754070368; (2) edit a Contact'sphoneto+40754070368in CRM so click-to-call routes back to yourself (caller-ID shows the Twilio US number on the recipient screen); (3) upgrade Trial → Pay-As-You-Go ($20 minimum) to remove the verified-only restriction. Did NOT silently spend on number provisioning or upgrade. - Prevention rule: when wiring any "Trial" provider integration, list the Trial restrictions in the demo brief before suggesting demo flows. Trial limits are external state, not something we can fix in code.
- Related files:
.env(TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN),apps/api/src/modules/calls/twilio.client.ts. - External docs: https://www.twilio.com/docs/errors/10002
- Area: AI / transcription / Whisper
- Symptom: activated Whisper at
WHISPER_MODEL=base(142 MB). On a real 30-minute Romanian sales call about heat pumps + photovoltaics, the transcript was so garbled that key proper nouns and technical terms broke:"Flair International"→"Flyer International","CRM"→"cereme","dumneavoastră"→"dumna vostra","dispoziție"→"disposie". Sentence structure mostly survived; word-level accuracy ~60-70%. On a TTS-clean 60-second sample with the same content,mediumproduced near-clean Romanian;large-v3cleaner still. - Root cause:
baseis a multilingual encoder; Romanian-specific phonetics and technical vocabulary are underweighted. The codebase default wasbasebecause the original sprint stub optimized for "demo without the disk hit". - Fix: bumped
WHISPER_MODELtolarge-v3(2.9 GB, ~0.7× real-time on CPU, 96-98% accuracy on RO). Caveat: a 30-minute call now takes ~45 minutes to transcribe end-to-end on CPU. Acceptable for async pipeline; will not scale to thousands of concurrent calls without GPU. - Prevention rule: never demo or pitch Romanian transcription on
base. Stub-mode default for dev is fine; the moment a real call is recorded, switch to at leastmedium. Document the trade-off (model size vs. wall-clock processing) in any "AI features" section visible to operators. - Related files:
.env(WHISPER_MODEL),apps/ai-worker/app/transcription.py,apps/ai-worker/app/config.py.
- Area: Docker / Python deps / image size
- Symptom: after enabling Whisper,
amass-ai-workerimage ballooned from 909 MB to 9.02 GB.docker imagesshowed the layer withpip install -r requirements.txtaccounted for ~8 GB. On Railway/Hetzner, layer storage cost spikes and pull time becomes painful. - Root cause: PyPI's default
torchwheels embednvidia-cublas-cu12,nvidia-cudnn-cu12,nvidia-cusparselt-cu12,nvidia-nccl-cu12,tritonand friends — all required for GPU acceleration. They install regardless of host capability. On a CPU-only host (Mac mini ARM, most cheap VPS) they are dead weight at boot and pull time. - Fix:
apps/ai-worker/Dockerfilenow installs torch FIRST from the CPU-only index beforerequirements.txt:RUN pip install --no-cache-dir torch==2.4.1 --index-url https://download.pytorch.org/whl/cpu && pip install --no-cache-dir -r requirements.txt. Image dropped to 1.96 GB (−78%). Whisper behaviour identical on CPU. - Prevention rule: any Python image that includes
torch/tensorflow/onnxruntimeon a CPU-only host MUST pin the CPU build. Pre-pin torch before the rest of requirements so transitive deps (whisper, whisperx) don't re-pull the GPU wheel. - Related files:
apps/ai-worker/Dockerfile,apps/ai-worker/requirements.txt. - External docs: https://pytorch.org/get-started/locally/
2026-05-14 — Webhook handlers run outside JWT/ALS context — activity.log() silently drops without explicit tenantId
- Area: webhooks / multi-tenant / activity log
- Symptom: real Twilio call completed, recording was downloaded, transcript saved, but the timeline tab on the contact showed no
call.completedentry. Logs revealedWARN Activity dropped — no tenant context for action=call.completedat thestatus_callbackwebhook hit. - Root cause:
ActivitiesService.log()reads tenant fromgetTenantContext()(AsyncLocalStorage). The wholerunWithTenant/ ALS chain is set up byTenantContextMiddleware, which only runs for JWT-authenticated requests. Twilio webhooks authenticate via signature (not JWT), so they skip that middleware.getTenantContext()returnsnull→ activity drop. TheCallrow already hastenantIdfrom the originalinitiateCall, but the webhook handler wasn't using it. - Fix:
ActivityEntrynow has optionaltenantIdandactorId.ActivitiesService.log()prefers them over ALS (entry.tenantId ?? ctx?.tenantId).CallsService.handleStatusWebhookpassestenantId: existing.tenantId, actorId: existing.userIdon thecall.completedlog. Verified:SELECT action, count(*) FROM activities WHERE tenantId='dana-test' GROUP BY actionnow showscall.completed: 1. - Prevention rule: every domain log/audit call inside a webhook handler must pass
tenantIdexplicitly. If a webhook needs to write to a tenant-scoped table, the entity it received from Twilio/Stripe/etc. already carriestenantIdfrom the original initiating request — use it. Never rely on ALS context in code reachable from webhook routes. - Related files:
apps/api/src/modules/activities/activities.service.ts,apps/api/src/modules/calls/calls.service.ts(handleStatusWebhook),apps/api/src/modules/calls/calls-webhook.controller.ts.
2026-05-14 — ImportProcessor bypassed the adapter chain on PDFs — 96-record export turned into 6376 "Missing company name" failures
- Area: importer / file-format routing
- Symptom: real GestCom PDF (149 pages, ~96 records) imported via
POST /api/v1/imports?type=COMPANIESreturned statusFAILEDwithtotalRows: 6376, succeeded: 0, failed: 6376. Every failed row had error"Missing company name". Adapter tests for GestCom passed in isolation — they never ran in the production path. - Root cause: three stacked issues. (1)
ImportProcessor.processwas wired to callPapa.parse(buffer.toString())directly on every uploaded file — it never invokedpickAdapter(). On a binary PDF,Papainterpreted each byte-line as a CSV row, producing thousands of garbage rows. (2)GestComAdapter.canHandle({fileName})required/gestcom|lucrari|amass/iin the filename, but a real export sent via WhatsApp arrives asunnamed document.pdf. (3) The Nume/Prenume/Email regex assumed one field per text line, butpdf-parsesometimes collapses an entire record onto a single line. - Fix: rewrote
ImportProcessor.processto callpickAdapter({fileName, magicBytes: buffer})first; Papa is now the fallback only for confirmed CSV. AddedStorageService.getObjectAsBuffer(). ExtendedGestComAdapter.canHandleto content-sniff forgestcom.ro/inside the buffer (PDF annotation dictionary). Rewrote Nume/Prenume/Oras regex with look-aheads. Verified live: real PDF →succeeded=94 + skipped=2 (dedup) + failed=0. Adapter unit tests still pass 14/14. - Prevention rule: every importer needs a smoke test that runs the full processor chain end-to-end on a real fixture, not just the adapter unit test. Adapter-only tests give false confidence because they never exercise the routing decision.
- Related files:
apps/api/src/modules/importer/import.processor.ts:71-138,apps/api/src/infra/storage/storage.service.ts:160-174,apps/api/src/modules/importer/adapters/factory.ts:29-35,apps/api/src/modules/importer/adapters/gestcom.adapter.ts:45-68,255-285,apps/api/src/modules/importer/adapters/gestcom.adapter.spec.ts.
2026-05-14 — Container ran stale dist/ for 10 days because docker compose up doesn't rebuild without --build
- Area: Docker / deploy / silent-drift
- Symptom:
/api/v1/cockpit/feedreturned404 NOT_FOUNDvia the live tunnel even thoughCockpitModulewas registered inapp.module.ts:211and the controller existed in source. The 4 cockpit commits (2346629,181c838,f6361f6,7fd93f6) were all on disk. - Root cause:
docker inspect amass-api --format '{{.Created}}'returned2026-05-03T20:44:52Z— 10 days old.docker exec amass-api stat -c '%y' /repo/apps/api/dist/main.jsconfirmed the bundled JS was from May 3.pnpm install/docker compose up -d(without--build) only restarts existing containers; it never triggers an image rebuild. Every code change since May 3 lived on disk but never reached the running runtime. - Fix:
docker compose -f infra/docker-compose.yml --project-directory infra build apifollowed byup -d --force-recreate api. After the rebuild, the previously-404 endpoint returned 200 with three real "deals-in-danger" widgets for the Dana tenant. Repeated forweb(new daily-calls UI tab) andai-worker(Whisper + CPU torch). - Prevention rule: after any non-trivial code change,
docker compose build <service>is required beforeup -d. Add--force-recreatewhen env vars or build context changed (otherwise Compose may keep the old container even after a new image is built). Add a session-start check that comparesdocker inspect ... .Createdagainst the last commit timestamp for that service's directory. - Related files:
infra/docker-compose.yml,apps/api/Dockerfile,apps/web/Dockerfile,apps/ai-worker/Dockerfile.
2026-05-12 — Always pnpm audit after a bulk pnpm update -r; supply-chain attacks land via patch/minor too
- Area: dependency management / supply-chain security
- Symptom: routine "patch + minor updates" pulled
@tanstack/react-router@1.169.2— a hair before two malicious sibling versions (1.169.5,1.169.8) published in the same minor line during the 2026-05-11 19:00 UTC TanStack supply-chain attack (CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx). Even though our specific version was published outside the attack window, npm's advisory DB flags the whole@tanstack/historypackage range until they narrow it, which would have failed CI silently. - Root cause: trusting npm package identity by namespace alone. The TanStack/router CI's pull_request_target "Pwn Request" + Actions cache poisoning + OIDC token extraction let an attacker publish under the legitimate trusted-publisher binding. No signed/attested publishing in npm by default.
- Fix: after every
pnpm update, runpnpm audit --prod --audit-level=high --jsonBEFORE pushing. If a transitive shows up as a known supply-chain hit, verify the SPECIFIC installed version manually (findnode_modules/.pnpm/.../package.jsonand grep for the@tanstack/setupoptionalDep marker + look forrouter_init.jspayload) before deciding to roll back or allowlist. - Prevention rule: CI's
dependency-auditstep is the line of defense. Don't downgrade--audit-levelto silence noise; instead, add explicit numeric-ID entries to the allowlist with a Why: comment and a re-check date. Re-validate every allowlist entry monthly. - Related files:
.github/workflows/ci.ymldependency-audit step,.github/workflows/redteam-weekly.yml,SECURITY_FINDINGS.mdSEC-TANSTACK-2026-05-11. - Related tests: run
bash /tmp/audit-filter.sh-style locally to confirm the allowlist filter excludes exactly what's intended.
- Area: ci / security / secret-scanning
- Symptom:
git pushrejected withGH013: Repository rule violations foundbecause.gitleaks.tomlcontained the Stripe-documented public test key (the one that ends indp7dc, sk_test_ prefix) as a literal allowlist entry. - Root cause: GitHub Push Protection scans all file content for secret patterns and rejects matches regardless of file purpose. It doesn't know "this is a gitleaks allowlist; the value is supposed to be here." It treats the literal as a leaked secret.
- Fix: replace literal allowlist entries with regex patterns that match the shape (
sk_test_[A-Za-z0-9]{24},AC[a-z0-9]{32}, etc.). This still allowlists the public test tokens against gitleaks, but the file itself never embeds a real secret. - Prevention rule: never embed a literal secret string in any tracked file, even comments, even allowlists, even tests. Use regex / fixture / env injection instead. Run
git pushearly on changes that touch security tooling so Push Protection feedback is surface-level, not at the end of a 5-commit batch. - Related files:
.gitleaks.toml
- Area: auth / websockets / notifications
- Symptom: realtime notifications never arrived; WS connection succeeded, room join silently used
tenant:undefined:user:<id>. - Root cause:
AuthService.issueTokenssigns JWT withtid(short, the standard short claim used elsewhere in the codebase);NotificationsGateway.handleConnectionwas readingpayload.tenantId. TypeScript did not catch this because the gateway annotatedverify<{ tenantId: string }>, accepting whatever shape we asked for without cross-checking the issuer. - Fix: gateway now reads
payload.tid. Addednotifications.gateway.spec.tsto lock in the contract. - Prevention rule: when adding any new JWT consumer (gateway, middleware, guard, BFF), grep the codebase for
signAsync\(payloadand confirm the consumer reads the same field names. Prefer a sharedJwtPayloadtype imported fromauth/. - Related files:
apps/api/src/modules/auth/auth.service.ts:481-493,apps/api/src/modules/notifications/notifications.gateway.ts,apps/api/src/modules/notifications/notifications.gateway.spec.ts - Related tests:
notifications.gateway.spec.ts(3 tests)
- Area: db / multi-tenant / security
- Symptom: as
app_userwithoutapp.tenant_id,SELECT count(*) FROM companiesreturned all rows across all tenants. - Root cause: legacy policies used
current_tenant_id() IS NULL OR tenant_id = current_tenant_id(). The OR was meant to allow unauthenticated migration jobs through, but it also let anyapp_userconnection that forgot toSET LOCAL app.tenant_idread everything. RLS being the last line of defense made this a P1. - Fix:
current_tenant_id()now returns a sentinel string instead of NULL whenapp.tenant_idis unset, so the OR branch is false. Migration20260504065000_rls_deny_missing_tenant. Regression inmulti-tenant.e2e.spec.ts. - Prevention rule: every new RLS policy must be tested with
SET LOCAL ROLE app_userAND noapp.tenant_id. Add the assertion tomulti-tenant.e2e.spec.tsfor every new tenant-scoped table. - Related files:
apps/api/prisma/migrations/20260504065000_rls_deny_missing_tenant/migration.sql,apps/api/test/multi-tenant.e2e.spec.ts - Related tests:
multi-tenant.e2e.spec.ts(7 tests)
- Area: web/e2e/runtime smoke
- Symptom:
pnpm exec playwright test e2e/auth-smoke.e2e.tsinitially reportedNo tests found; Docker smoke againstlocalhost:5173can miss Caddy-routed API behavior. - Root cause: Playwright default test matching did not include
*.e2e.ts; Docker stack's correct browser origin is Caddy athttp://localhost, not raw Vite/nginx port assumptions. - Fix: added
testMatch: '**/*.e2e.ts'and defaulted PlaywrightbaseURLtohttp://localhost. - Prevention rule: e2e files with non-default suffixes must be included in
testMatch; Docker browser smoke should use the same origin users hit through Caddy. - Related files:
apps/web/playwright.config.ts,infra/caddy/Caddyfile - Related tests:
PLAYWRIGHT_BASE_URL=http://localhost ... pnpm exec playwright test e2e/auth-smoke.e2e.ts
- Area: web/e2e/workspace tooling
- Symptom: root-level
pnpm exec playwright test e2e/auth-smoke.e2e.tsfailed withCommand "playwright" not found. - Root cause:
@playwright/testis installed inapps/web/package.json, not rootpackage.json. - Fix: run Playwright from
apps/webwithpnpm exec playwright ...or use a filtered workspace command. - Prevention rule: workspace-local CLI dependencies must be run from the owning package or through
pnpm --filter. - Related files:
apps/web/package.json,apps/web/playwright.config.ts - Related tests: auth smoke and critical CRM smoke passed from
apps/web.
- Area: web/e2e/auth
- Symptom: critical browser smoke got redirected back to
/login?redirect=/app/companies. - Root cause: the test clicked
Conectareand immediately navigated to/app/companies, aborting the in-flightPOST /api/v1/auth/login. - Fix: separated UI auth coverage into
auth-smoke.e2e.ts; critical CRM smoke uses one API login and seeds the browser session. - Prevention rule: after UI auth submit, wait for the auth response or authenticated URL before any explicit navigation.
- Related files:
apps/web/e2e/auth-smoke.e2e.ts,apps/web/e2e/critical-crm-smoke.e2e.ts - Related tests: auth smoke and critical CRM smoke both passed.
- Area: web/e2e/UI overlays
- Symptom: critical smoke hung around the reminder form with the cookie banner still visible.
- Root cause: fixed cookie banner can cover or intercept lower-right UI actions in headless browser viewports.
- Fix: added
dismissCookieBanner()at the start of the critical CRM smoke. - Prevention rule: browser smoke tests should close global overlays before interacting with page workflows.
- Related files:
apps/web/e2e/critical-crm-smoke.e2e.ts - Related tests:
PLAYWRIGHT_BASE_URL=http://localhost ... pnpm exec playwright test e2e/critical-crm-smoke.e2e.ts
- Area: web/API contract
- Symptom: attachment UI would call
window.open(undefined, ...)for downloads. - Root cause: API returns
{ downloadUrl, expiresIn, fileName, mimeType }, but web client typed and read{ url }. - Fix: updated web attachment client/component to use
downloadUrl; added a regression test. - Prevention rule: before wiring UI to API responses, check the controller/service or shared schema; do not guess response field names.
- Related files:
apps/web/src/features/attachments/api.ts,apps/web/src/features/attachments/AttachmentsTab.tsx,apps/web/src/features/attachments/AttachmentsTab.test.tsx,apps/api/src/modules/attachments/attachments.service.ts - Related tests:
pnpm --filter @amass/web test -- src/features/attachments/AttachmentsTab.test.tsx
- Area: env/config/logging
- Symptom:
pnpm --filter @amass/api test:e2efailed at Nest startup with Pinodefault level: must be included in custom levels. - Root cause: local
.envhadLOG_LEVEL=; code usedprocess.env['LOG_LEVEL'] ?? fallback, so the empty string bypassed the default log level. - Fix: added
resolveLogLevel()to trimLOG_LEVELand fall back todebug/infowhen it is empty. - Prevention rule: for optional env vars, normalize empty strings before passing values into strict libraries.
- Related files:
apps/api/src/config/logging.ts,apps/api/src/config/logging.spec.ts,apps/api/src/app.module.ts - Related tests:
pnpm --filter @amass/api exec vitest run --config vitest.config.unit.ts src/config/logging.spec.ts,pnpm --filter @amass/api test:e2e
- Area: tests/env/Nest module imports
- Symptom:
test/calls.e2e.spec.tsfailed with expected200, got403onPOST /api/v1/calls/:id/ai-result. - Root cause:
AI_WORKER_SECRETwas set inbeforeAll, butAppModuleimports triggeredloadEnv()earlier and cached env without the test secret. - Fix: set deterministic
AI_WORKER_SECRETinapps/api/test/env.setup.tsandapps/api/test/global.setup.tsbefore application modules import. - Prevention rule: if production code reads env at module construction/import time, test-only env values must be set in Vitest setup/global setup, not inside
beforeAll. - Related files:
apps/api/test/calls.e2e.spec.ts,apps/api/test/env.setup.ts,apps/api/test/global.setup.ts,apps/api/src/config/env.ts - Related tests:
pnpm --filter @amass/api exec vitest run test/calls.e2e.spec.ts,pnpm --filter @amass/api test:e2e
- Area: runtime verification / Docker / API smoke
- Symptom:
docker compose psfrom repo root failed with "no configuration file provided";curl http://localhost:3000/healthreturned 404. - Root cause: compose files live under
infra/, and Nest sets the global API prefix to/api/v1. - Fix: use
docker compose -f infra/docker-compose.yml psand smokehttp://localhost:3000/api/v1/health/http://localhost:3000/api/v1/health/ready. - Prevention rule: before declaring runtime health, verify the repo-specific compose file and API prefix instead of assuming root compose or root health routes.
- Related files:
package.json,infra/docker-compose.yml,apps/api/src/main.ts,apps/api/src/modules/health/health.controller.ts - Related tests:
curl -fsS http://localhost:3000/api/v1/health,curl -fsS http://localhost:3000/api/v1/health/ready
- Area: release workflow / documentation
- Symptom:
STATUS.mdcontained many older claims about test counts, coverage, modules, and launch readiness that could be misread as verified today. - Root cause: project status docs accumulated useful history without a current-session truth block at the top.
- Fix: add a current-session audit section and explicitly label older content as historical unless rechecked.
- Prevention rule: every status doc must distinguish current verification from historical claims. (As of 2026-05-11 those snapshot docs —
STATUS.md,TEST_REPORT.md,UNFINISHED.md,LAUNCH_CHECKLIST.md— were removed because they degraded into the same trap. UseCHANGELOG.md+git logfor history andRELEASE_CHECKLIST.mdfor the live launch gate.) - Related files:
RELEASE_CHECKLIST.md,CHANGELOG.md,AGENTS.md - Related tests: not applicable; documentation/process change
This file is maintained by Claude Code across sessions. Every time something breaks, surprises, or wastes time, add an entry here so future sessions don't repeat the mistake.
Format: newest entries on top. Each entry should be short, factual, and actionable. Include the root cause, not just the symptom.
### YYYY-MM-DD — short title
- **Sprint / area:** S1 / auth
- **Symptom:** what broke or surprised
- **Root cause:** why it happened
- **Fix:** what made it work
- **Lesson:** the rule to follow next time- Multi-tenant leaks — any query missing
tenantIdfilter. - Migration drift — schema changes not reflected in migrations.
- Env var surprises — missing/typo'd env at startup.
- Docker pitfalls — context paths, volume permissions, healthcheck timing, build cache.
- Prisma gotchas — N+1, transaction scope, middleware order.
- TypeScript holes — places we caught ourselves reaching for
any. - Test flakes — testcontainer startup races, port collisions.
- Auth/security — JWT mistakes, RLS bypass, presigned URL leakage.
- Frontend state — TanStack Query cache invalidation, race conditions.
- Sprint / area: tags / docker deployment
- Symptom:
GET /api/v1/tags→ 500 "Cannot read properties of undefined (reading 'findMany')" even after fixing TagsModule and redeploying dist.EntityTagmodel existed in schema.prisma but not in the runtime Prisma client. - Root cause: The Docker image bakes in a Prisma client generated at build time. When new models are added (e.g.
EntityTaginfeat(tags)commit), the schema is copied to the container but the client is NOT regenerated.typeof prismaClient.entityTagreturnsundefined. - Fix: Run
docker exec amass-api node_modules/.pnpm/node_modules/.bin/prisma generate --schema=apps/api/prisma/schema.prismathen restart. - Lesson: After any
prisma schemachange that adds/removes models, regenerate the client inside the container (prisma generate). Thedocker cp distonly copies compiled JS — it does NOT update the Prisma client innode_modules/@prisma/client.
- Sprint / area: tags / nestjs modules
- Symptom: All tag endpoints 500 with "Cannot read properties of undefined (reading 'findMany')" — TagsService couldn't use PrismaService.
- Root cause:
TagsModuleimports only[AuthModule, AccessControlModule].PrismaModulewas not imported, so NestJS DI couldn't injectPrismaServiceintoTagsService. - Fix: Added
PrismaModuleto imports array intags.module.ts. - Lesson: Every NestJS module that uses
PrismaServicemust importPrismaModule. Check this when creating new modules.
- Sprint / area: testing
- Symptom: Multiple curl tests returning VALIDATION_ERROR or INTERNAL_ERROR due to wrong field names / shapes.
- Root cause: API schemas are strict and different from what one might guess. Key discoveries:
- Notes/Timeline URL:
/:subjectType/:subjectId/notes— subjectType must be singular UPPERCASE (COMPANY, notCOMPANIES) - Deals:
pipelineIdrequired separately fromstageId;valuemust be string"5000.00"(decimal), not number - Invoices: field is
lines(notitems);unitPrice/quantity/vatRateare all strings (decimal format) - Tasks: status changes via
POST /tasks/:id/completeandPOST /tasks/:id/reopen(not PATCH); reopen status =OPEN - Tags: unassign is
DELETE /tags/:id/assign/:entityId(entityId in path, not body) - Webhooks: events enum is
COMPANY_CREATED(notcompany.created) - Lead source enum:
WEB(notWEBSITE) - Custom field bulk set:
{values: [{fieldDefId, value: string}]} - Download URL field:
downloadUrl(noturl) - Attachments in dev: presigned URL uses
minio:9000(internal Docker host) — PUT from host fails with 403 (signature mismatch); workaround: upload directly viamcfrom MinIO container
- Notes/Timeline URL:
- Fix: Documented all shapes above.
- Lesson: Before writing API consumers or tests, read the Zod schema in
packages/shared/src/schemas/. Don't guess field names.
- Sprint / area: reports / raw SQL
- Symptom:
GET /reports/dashboardreturned 500 withcolumn d.stage_id does not exist. Three of the five raw$queryRawblocks inReportsServicereferencedtenant_id,created_at,deleted_at,stage_id,duration_sec— and those columns don't exist; the actual columns aretenantId,createdAt,deletedAt,stageId,durationSec. - Root cause: Most Prisma models in this repo do NOT use
@map. Postgres stores the column with the camelCase identifier and requires it to be quoted:"tenantId". There are exceptions:invoices,email_tracking,webhook_deliveriesetc. do use@map. The reports service mixed both worlds in the same file, which made the inconsistency hard to spot during code review. - Fix: Replaced snake_case with
"camelCase"in every query that hits a non-@maptable; left theinvoicesqueries as-is (they correctly use snake_case). Verified post-fix with a liveGET /reports/dashboardreturning 200. - Lesson: Before writing raw SQL against any table in this repo, run
\d <table>against the live Postgres or grep the schema for@@map(and@map(on the model — never assume snake_case. When mixing camelCase and snake_case columns in the same file, add an inline comment marking the non-default convention so the next reader doesn't have to reconstruct it.
- Sprint / area: dev loop / docker
- Symptom: Edited
reports.service.ts, ranpnpm --filter @amass/api build(which writes toapps/api/dist/on the host), restarted the container — the fix didn't take effect. Container was still running the olddist/baked in at image-build time. - Root cause:
apps/api/DockerfiledoesCOPY apps/api ./apps/apiand thenpnpm --filter @amass/api buildinside the build stage, so the running container'sdist/is whatever the image was built with. Restarting just re-execs the same image. - Fix:
docker cp apps/api/dist amass-api:/repo/apps/api/thendocker compose restart api. (Long-term: add a dev compose override that bind-mountsapps/api/src/and runsnest start --watchinstead ofnode dist/main.js.) - Lesson: When a host build doesn't show up in the container, the answer is
docker cp(fast) or rebuild the image (slow). Both beat 30 minutes of "but I rebuilt it" debugging.
- Sprint / area: access-control / 19 controllers in one push
- Symptom: Cedar coverage on Nest controllers stuck at 18/64 because nobody wanted to write the same
@RequireCedar({...})block 50 times. - Root cause: It looked like adding Cedar required (a) editing the module to import
AccessControlModule, (b) editing the controller to import the guard + decorator + add to@UseGuards, (c) editing every write/delete handler. The module step is the one that made it feel heavy. - Fix:
AccessControlModuleis@Global()— module imports are NOT needed. Adding Cedar is just (b) + (c) per controller. With that realisation, mass-rolled out across 14 controllers / 62 handlers via a delegated agent task, then verifiedpnpm lint && tsc --noEmitclean. - Lesson: When a code pattern looks expensive to roll out, check what's actually required vs. what the docs say is required.
@Global()on a module turns "edit N modules" into "edit N controllers" — sometimes that 2× difference is what unblocks a whole quality improvement.
- Sprint / area: CI / dependency hygiene
- Symptom: Daily workflow failed with
Unable to resolve action zaproxy/action-baseline@4ca41f5d416ba7c0a5e1c84a3ff9ec8efd34ee3a, unable to find version. The pin claimed to be# v0.12.0but the SHA didn't exist in the upstream repo. - Root cause: A previous session inserted a fabricated 40-char hex SHA next to a real version tag comment. Reviewers see
# v0.12.0, trust the comment, miss that the hash is wrong. GitHub Actions only fetches by SHA, so the comment is documentation only — there is no verification step. - Fix:
git ls-remote https://github.com/zaproxy/action-baseline.gitlists every real ref (refs/tags/v0.14.0→7c4deb10e6261301961c86d65d54a516394f9aed). Repinned to that verified SHA + bumped to v0.14.0 since we touched the line anyway. - Lesson: When pinning third-party Actions to a SHA, verify the SHA exists upstream — never type one in from memory or trust a "v0.x.x" comment without
git ls-remoteproof. The real SHA after^{}(dereferenced tag) is the canonical commit hash to pin.
- Sprint / area: web / lint
- Symptom: ESLint flagged
setQuery(''),setDebounced(''),setHighlighted(0)inside auseEffect(() => { if (open) {...} }, [open])reset block, andsetHighlighted(0)inside a clamp effect. - Root cause: React 19 + the
react-hooksplugin now treatssetState()calls in an effect body as a code smell — they cause cascading re-renders that the effect was supposed to avoid. - Fix: Two patterns:
- Mount-time reset: split
<CommandPalette>(gates onopen) from<PaletteBody>(owns the state). Whenopenflips false→true the outer remounts the body fresh, so default state is automatic — no reset effect needed. - Clamp inline: instead of an effect that clamps
highlightedwhen rows shrink, derive the clamped value on every render:const highlighted = rows.length === 0 ? 0 : Math.min(highlightedRaw, rows.length - 1).
- Mount-time reset: split
- Lesson: Don't reach for
useEffect(() => setState(default), [trigger])— either remount via a parent gate or derive inline. Effects are for syncing with external systems; React state should be derived or initialised directly.
- Sprint / area: api / tests
- Symptom:
Property 'mock' does not exist on type '(entry: AuditEntry) => Promise<void>'when readingh.audit.log.mock.calls[0]in a service spec. - Root cause: The test stub is
{ log: vi.fn() } as unknown as ConstructorParameters<typeof Service>[1]. The cast erases thevi.Mockwrapper from the type — TS sees only the real signature, which doesn't have.mock. - Fix: Wrap with
vi.mocked(h.audit.log).mock.calls[0][0]. Vitest'svi.mocked()is exactly this: it asserts at the type level that the function is a mock without changing runtime behaviour. - Lesson: When you cast a mock to a typed constructor parameter, you give up direct
.mockintrospection on that handle. Reach forvi.mocked()whenever you need to inspect call args after a cast.
- Sprint / area: api / prisma
- Symptom:
tx.deal.findMany({ select: { ..., company: { select: { name: true } } } })typechecked at runtime but TS errored:Property 'name' does not exist on type 'never'. - Root cause:
DealhascompanyId String?but nocompany Company? @relation(...)inverse — onlypipelineandstagerelations are declared. Prisma generatescompanyIdas a foreign-key column without the navigation property, soselect: { company: ... }resolves tonever. - Fix: Two-step query — fetch
companyIdon the deal, thentx.company.findMany({ where: { id: { in: companyIds } } })and join in JS via aMap. - Lesson: When adding a Prisma include/select for a relation, double-check that the inverse exists in
schema.prisma. A bare FK column without@relation(...)won't surface as a navigable property even if the column itself is queryable.
- Sprint / area: api / test patterns
- Symptom: Inconsistency across early specs — some used
runWithTenant(tenantId, fn)(2-arg), somerunWithTenant(tenantId, level, fn)(3-arg), some forgot to mock side-effect deps (audit, embedding, workflows). - Fix / pattern: Every new service spec uses this
build()skeleton:The dual-overloadvi.mock('../../infra/prisma/tenant-context', () => ({ requireTenantContext: vi.fn(() => ({ tenantId: 'tenant-1', userId: 'user-1' })), })); function build() { const tx = { /* every Prisma model the service touches */ }; const prisma = { runWithTenant: vi.fn(async ( _id: string, levelOrFn: string | ((t: typeof tx) => unknown), fn?: (t: typeof tx) => unknown, ) => (typeof levelOrFn === 'function' ? levelOrFn : fn!)(tx)), } as unknown as ConstructorParameters<typeof Service>[0]; // …mock audit / activities / embedding / workflows / queue identically }
runWithTenantmock means tests work whether the service calls the 2-arg or 3-arg form. Pattern shipped:companies,contacts,clients,contracts,contact-segments,forecasting,duplicates,tasks,email,workflows,totp,brief. - Lesson: Standardise the spec scaffold across services so coverage rounds compose without rewriting boilerplate; keep the dual-arity
runWithTenantmock so a service refactor between read-only and read-write paths doesn't break specs.
- Sprint / area: infra / prisma
- Symptom: CI
prisma-driftjob failed onmainwith ~100 diff entries — 14 missing enum types, dozens of TEXT→enum conversions, index renames, and FK redefinitions. - Root cause: A previous session edited
schema.prisma(commit150d50d"refactor: close remaining tech debt") without runningprisma migrate dev, so the schema declared things that no migration created. CI runsprisma migrate diff --from-migrations --to-schema-datamodelwhich correctly detected the gap. - Fix:
pnpm exec prisma migrate diff --scriptproduces a draft, but itsDROP COLUMN + ADD COLUMNfor enum conversions DESTROYS DATA. Hand-craft the migration replacing each pair withALTER COLUMN <col> TYPE <Enum> USING (<col>::text::<Enum>). Tested on a real Postgres+pgvector shadow DB with seed data — every row survived. Migration:20260424100000_schema_catchup. - Lesson: (1) Never edit
schema.prismawithout immediately runningprisma migrate dev— drift compounds quickly. (2) When generating a catch-up migration, always start from--script, then audit for destructiveDROP COLUMN+ replace withALTER COLUMN ... TYPE ... USING. Postgres TEXT→enum casts via USING preserve data when values match enum variants; if they don't, the migration fails loud (correct behaviour, not silent data loss).
- Sprint / area: infra / prisma
- Symptom: Catch-up migration applied cleanly but rerunning
prisma migrate diffstill flagged[+] Added unique indexonforecast_quotasandformula_fields. - Root cause: Earlier hand-written migrations declared
UNIQUE(tenant_id, ...)as a TABLE CONSTRAINT insideCREATE TABLE. Postgres exposes this as aCONSTRAINTobject plus an auto-generated index. Prisma's@@unique([...])schema annotation expects a standaloneUNIQUE INDEX; even though the column list and name match, Prisma's diff sees them as different object kinds. - Fix: In the catch-up migration, drop the constraint and recreate as a unique INDEX with the same name:
ALTER TABLE x DROP CONSTRAINT x_..._key; CREATE UNIQUE INDEX x_..._key ON x(...). - Lesson: Never use inline
UNIQUE(col1, col2)in aCREATE TABLEfor columns that have a Prisma@@unique([...])annotation. Always emitCREATE UNIQUE INDEX <name> ON <table>(...)so Prisma's view of the DB matches its view of the schema.
- Sprint / area: testing / vitest
- Symptom:
vi.mocked(h.prisma).call.findFirst.mockResolvedValue(...)failed typecheck with "Property 'mockResolvedValue' does not exist" on the Prisma delegate. - Root cause:
h.prismais built as a plain object literal cast toPrismaServiceviaas unknown as ConstructorParameters<...>[0].vi.mocked()only re-types objects that came directly from avi.mock()factory — when the input is a cast, it sees the static Prisma client type, not the underlying mock. - Fix: Hold separate references to the mock objects in the test helper (
prismaPhone = { findFirst: vi.fn() }) and assign them onto the prisma stub. Test code drivesh.prismaPhone.findFirst.mockResolvedValue(...)directly — typed asMock, novi.mocked()needed. - Lesson: When stubbing PrismaService for unit tests, expose the mock spies at the top level of the build helper. Don't try to reach through
vi.mocked(svc)into a deeply-typed Prisma delegate.
- Sprint / area: testing / e2e
- Symptom:
auth.e2e.spec.tsstarted returning 403 CSRF_HEADER_MISSING on/auth/refreshand/auth/logoutafterCsrfHeaderMiddlewareshipped. - Root cause: The new middleware required
X-Requested-With: amass-webon mutative cookie-authenticated requests. Existing supertest calls didn't set the header. - Fix: Add
.set('X-Requested-With', 'amass-web')to every mutative call in the affected spec, mirroring what the real SPA does. - Lesson: When adding a guard or middleware that requires a new request header, immediately grep the test suite for handlers under that route and fix them in the same commit. Otherwise the next CI run is red and the cause looks unrelated.
- Sprint / area: Tier B / formula-fields
- Symptom: Nevoie de expresii calculate definite de tenant ("MRR * 12", "CONCAT(firstName, ' ', lastName)") fără risc de code injection.
- Root cause:
eval()saunew Function()permit execuție arbitrară de cod în contextul serverului. - Fix: Parser recursive descent manual: tokenizer → parseExpr → parseTerm → parseFactor → callBuiltin. Whitelist de built-ins (CONCAT, IF, UPPER, LOWER, LEN, NUMBER, ROUND). Variabilele sunt lookup în
contextmap, nu în scope global. - Lesson: Orice sandbox de expresii definit de utilizator în Node.js TREBUIE să evite
eval/Function. Recursive descent cu whitelist este simplu, testabil și suficient pentru formulele CRM.
- Sprint / area: Tier B / territories
- Symptom:
catch (e)undeenu este folosit dă eroare lint. - Root cause:
@typescript-eslint/no-unused-varsinclude și parametrii catch. - Fix: Folosit
catch {(fără parametru) — sintaxă validă în ES2019+/TS. - Lesson: Când vrei să ignori eroarea din catch, scrie
catch {nucatch (_e)saucatch (e).
- Sprint / area: Tier B / cases
- Symptom:
$executeRawcu cast la enum Prisma ("CasePriority") necesită ghilimele duble în SQL Postgres. - Root cause: Enum-urile Prisma sunt tipuri Postgres cu majuscule; cast via
::necesită exact"CasePriority"cu ghilimele (case sensitive). - Fix:
${next}::"CasePriority"în template literal$executeRaw. - Lesson: La cast enum Postgres în raw SQL, folosește întotdeauna ghilimele duble:
'WON'::"DealStatus".
- Sprint / area: Tier B / PWA
- Symptom: Risc de leak multi-tenant dacă service worker-ul cache-uiește răspunsuri API între utilizatori (alt tenant primește date altui tenant din cache).
- Root cause: Default fetch interception cache-uiește toate GET requests; combinat cu JWT-uri per-tenant, două sesiuni pot returna răspunsul greșit.
- Fix: Service worker (
apps/web/public/sw.js) verificăurl.pathname.startsWith('/api/')și face skip → mereu network. Doar app shell-ul (HTML, manifest, icons) este cache-uit. - Lesson: Pentru PWA în context multi-tenant + auth, NU intercepta cereri API. Cache-uiește doar resurse statice publice fără semnificație tenant-specifică.
- Sprint / area: Tier B / Cases & Orders
- Symptom: Două creates concurente pot primi același număr dacă folosim
count() + 1. - Root cause:
count()nu blochează rândurile inserate concurent; race condition între tx-uri. - Fix: Folosit
findFirst({ orderBy: { number: 'desc' } })apoi insert. UNIQUE constraint(tenant_id, number)garantează că un duplicat va eșua și aplicația poate retry. Tx-ul fiind serializabil subrunWithTenantreduce probabilitatea conflictului. - Lesson: Pentru numere secvențiale per-tenant, combină
findFirst(cel mai mare) cu UNIQUE constraint. Dacă volumul crește, migrează la sequence Postgres dedicat per tenant sau lock advisory.
- Sprint / area: S53 / Leads / shared schemas
- Symptom: TypeScript error
Module './schemas/company' has already exported a member named 'LeadSourceSchema'. Bothcompany.tsandleads.tsexported it. - Root cause: Agent-generated
leads.tsredeclared the enum instead of importing fromcompany.tswhere it already existed (LeadSource was originally defined for Company). - Fix: Removed duplicate declaration from
leads.ts, addedimport { LeadSourceSchema } from './company'and re-exported it. - Lesson: Before adding an enum to a new schema file, grep shared/schemas/ for existing exports with the same name. In this codebase,
LeadSourcelives incompany.tsand is shared.
- Sprint / area: S53 / lead-scoring spec
- Symptom:
mockRunWithTenantmock consumed in wrong order → wrong values in service, tests failed. - Root cause:
gatherFactors()usesPromise.all([activities, calls, emailMessages, deals])for 4 of the 6 calls. ForentityType='company',emailMessagesresolves viaPromise.resolve(0)(notrunWithTenant), so only 3 calls in the Promise.all. The spec had an extra mock for email. - Fix: Reordered spec mocks: exists → activities → calls → deals → lastActivity → upsert (6 calls, not 7).
- Lesson: When mocking sequential + parallel async calls, map the ACTUAL code path.
Promise.alldoes NOT change themockResolvedValueOnceconsumption order but skipped branches (likePromise.resolve(0)shortcircuits) DO reduce the call count.
- Sprint / area: S53+ / tests / ESLint
- Symptom:
pnpm lintfailed with 13 errors —as anyin mock object casts in spec files. - Root cause:
eslint.config.mjsappliedno-explicit-any: errorglobally, including*.spec.ts. - Fix: Added ESLint override for
**/*.spec.tsfiles relaxingno-explicit-any: off. - Lesson: Partial mock objects in test files legitimately need
as any(oras unknown as Type). Add the test file override from the start, not after the fact.
- Sprint / area: S47–S55 / CI / MinIO
- Symptom: CI e2e job likely failing because both a
services: minio:container and aStart MinIOstep tried to bind port 9000 simultaneously. - Root cause: The MinIO service container was added when the MinIO step was already present; both got merged into the same branch during a non-fast-forward merge.
- Fix: Removed the
services: minio:section (health check usescurlwhich isn't in the MinIO image anyway). Kept only the explicit Dockerrunstep. - Lesson: MinIO's healthcheck requires curl which is NOT in the official MinIO image. Use a step with
docker run+ manualuntil curlwait instead of a service container.
2026-04-17 — CI — JwtAuthGuard DI failures repeat across multiple modules (reactive vs upfront audit)
- Sprint / area: S15–S16 / CI / module wiring
- Symptom: CI kept failing with a cascade:
Nest can't resolve dependencies of the JwtAuthGuard (?). Please make sure that the argument JwtService at index [0] is available in the XModule context.→ app couldn't bootstrap → ALL 13 e2e specs failed withTypeError: Cannot read properties of undefined (reading 'tenant')inafterAll(becauseprismawas never assigned inbeforeAll). - Root cause: Multiple new late-sprint modules (
EmailSequencesModule,ContactSegmentsModule,QuotesModule) were scaffolded without importingAuthModule. Because I fixed them one at a time as CI revealed them, three separate CI runs failed with essentially the same bug. Each fix only unmasked the next one. - Fix: Comprehensive upfront grep:
for each module dir, if controller uses JwtAuthGuard/UseGuards and module doesn't import AuthModule/JwtModule → flag. Then fix ALL in one commit. - Lesson 1: When a pattern like "module missing AuthModule" surfaces, immediately audit every module — don't fix one and push. The effort of a 10-line shell loop saves 3+ CI run cycles.
- Lesson 2: The
prisma undefined in afterAllerror is always a secondary symptom of app bootstrap failure. Don't chase it directly — find the root bootstrap error first. - Lesson 3: Whenever creating a new NestJS module with a protected controller: always add
AuthModuletoimports[]on the spot, before committing. Treat it as a checklist item alongside creating the service/controller files. - Lesson 4: The AuditModule fix was special:
AuditModule→AuthModule→AuditModule= circular. Solution: importJwtModule.registerAsync()directly inAuditModuleinstead ofAuthModule. For all non-circular cases, importAuthModule.
- Sprint / area: S15–S16 / CI / migrations
- Symptom:
column "tenant_id" does not existduringprisma migrate deployin CI. Theattachmentstable had been created with"tenantId"(camelCase, no@map) but a later migration'sCREATE INDEXreferenced"tenant_id"(snake_case). - Root cause: Hand-written migration SQL for a new index copy-pasted the conventional snake_case column name without checking how the original migration created the column.
- Fix: Changed the index to use
"tenantId"(matching the actual column name in the DB). - Lesson: Before writing any hand-crafted DDL that references existing columns, check the original migration or
\d tablenameto see exact column names. This repo does NOT use@mapon most fields, so column names are camelCase in Postgres. Don't assume snake_case.
- Sprint / area: S20/S21 / test infrastructure
- Symptom:
pnpm testfailed withEnvironment validation failed — ENCRYPTION_KEY: Requiredeven thoughglobalSetupwas loading the.envfile. The env vars were present in the setup process but not in the worker processes that actually import NestJS modules. - Root cause:
auth.module.tscalledconst env = loadEnv()at module evaluation time (top-level, outside any function). When vitest workers importAppModule→AuthModule, the module-level code runs beforesetupFilescan inject env vars. TheglobalSetupruns in the main process; env vars set there do NOT propagate to worker processes. - Fix: Changed
JwtModule.register({ secret: env.JWT_SECRET })toJwtModule.registerAsync({ useFactory: () => { const env = loadEnv(); return { ... }; } }). The factory runs lazily when the DI container is actually built, by which timesetupFileshas already set the env vars in the worker process. - Lesson: Never call
loadEnv()(or any Zod-validated env schema) at module level in NestJS. Always lazy-load insideuseFactory/useClass/ provider factories. Module-level code runs at import time, before any test setup can run. This applies to any side-effectful initialization: DB connections, external clients, etc.
- Sprint / area: S21 / Workflow models
- Symptom:
PrismaClientKnownRequestError: The column 'workflows.tenantId' does not exist in the current database. Prisma generated JS usedtenantIdbut the actual DB column (from the migration SQL) wastenant_id. - Root cause: Prisma schema models used camelCase field names (
tenantId,isActive, etc.) without@map("snake_case")annotations. The migration SQL (written manually) used snake_case column names. Prisma's generated client uses the schema field names, not the DB column names — so there was a permanent mismatch. - Fix: Added
@map("tenant_id"),@map("is_active"), etc. to every camelCase field inWorkflow,WorkflowStep, andWorkflowRunmodels. Then rannpx prisma generateto regenerate the client. - Lesson: When writing a migration SQL by hand AND using camelCase field names in the schema, you must add
@map("snake_case")to every field. Alternatively, letprisma migrate devgenerate the SQL (it respects@mapautomatically). Mixing hand-written SQL with a schema that lacks@mapannotations always leads to this mismatch.
- Sprint / area: S21 / DI / module wiring
- Symptom:
Nest can't resolve dependencies of the JwtAuthGuard (?). Please make sure that the argument JwtService at index [0] is available in the ReportsModule context. - Root cause:
JwtAuthGuarddepends onJwtService, which is provided byJwtModuleinsideAuthModule. Any NestJS module whose controllers use@UseGuards(JwtAuthGuard)must importAuthModuleto makeJwtServicevisible in that module's DI context. Six later-sprint modules (ai, calls, email, gdpr, reports, workflows) were missing this import. - Fix: Added
AuthModuleto theimportsarray in all six modules. - Lesson: Every module that uses JWT-protected routes MUST import
AuthModule. This is easy to miss when creating new modules — add it as a checklist item when wiring up a new controller with@UseGuards(JwtAuthGuard). Consider makingAuthModule@Global()to avoid this repetition (trade-off: implicit vs explicit dependency).
- Sprint / area: S21 / env validation
- Symptom:
ZodError: ANTHROPIC_API_KEY: String must contain at least 1 character(s)when.envhasANTHROPIC_API_KEY=(empty value). - Root cause: An empty value in
.envis parsed as an empty string"", not asundefined.z.string().min(1).optional()passes onundefinedbut rejects"". Devs commonly leave optional keys blank in.envfiles. - Fix: Wrapped each optional key with
z.preprocess((v) => (v === '' ? undefined : v), z.string().min(1).optional())to convert empty strings toundefinedbefore Zod validates. - Lesson: For optional env vars, always use
z.preprocessto coerce""→undefined. The patternz.string().min(1).optional()is not sufficient when env files may containKEY=(blank value). This applies to any env var that is optional but must be non-empty if provided.
- Sprint / area: S7 / reminders / e2e tests
- Symptom: A new test that creates a reminder with
remindAt = now + 700ms, then polls Postgres forstatus = 'FIRED', passed in isolation (vitest run test/reminders.e2e.spec.ts→ 908ms) but failed reliably when run as part of the full suite — the polling loop timed out at 4 seconds with the row still PENDING. - Root cause: vitest defaults to file-parallel execution via a thread pool. Every test file imports
AppModule, which registers aRemindersProcessorBullMQ worker subscribed to the sharedremindersqueue on Redis. When multiple files boot in parallel, you get N workers from N differentAppModuleinstances all listening on the same queue. A job enqueued by the reminders test can be dispatched to a worker belonging to a different file's app — and that other app may already be tearing down (itsapp.close()runs at the end of its ownafterAll), so the worker grabs the job and then the Prisma connection or BullMQ connection vanishes mid-process. The job either silently disappears or repeatedly errors out, never flipping the row to FIRED before the test gives up. - Fix: Set
fileParallelism: falseinapps/api/vitest.config.ts. Test files now run sequentially, so exactly one set of workers per queue is alive at any moment. Total runtime for the full suite went from ~6s parallel to ~7s sequential — the parallelism wasn't buying us much because the tests are I/O-bound on the shared Postgres/Redis/MinIO anyway. - Lesson: Any e2e suite that boots an
AppModulecontaining BullMQ workers MUST run files sequentially. The shared infra (Postgres, Redis, MinIO) was already living dangerously under file-parallel execution — they only worked because each test file uses unique tenant slugs and unique storage keys. BullMQ, by contrast, has no per-test namespace knob: queue names are global to the Redis instance. Either run files sequentially, or namespace the queue per test process (e.g.reminders-${process.pid}), and the former is much simpler. Rule of thumb for this repo: if a test file importsAppModule, treat the whole suite as sequential.
- Sprint / area: S6 / curl verification
- Symptom: Brand new attachment routes were mapped in tests (vitest passes), but the curl verification against the running API got
404 NOT_FOUND: Cannot POST /api/v1/COMPANY/.../attachments/presign. Stack trace pointed toapps/api/dist/common/middleware/tenant-context.middleware.js. - Root cause:
apps/api/package.jsonhas"start": "node dist/main.js"(production-style). Vitest transpiles fromsrc/on the fly, so tests reflect the latest code, butpnpm startboots whatever was lastnest build'd intodist/. After adding new modules I forgot to rebuild. - Fix:
pnpm buildbeforepnpm start(or usepnpm devwhich isnest start --watchand readssrc/). - Lesson: For curl verification of fresh code, either (a)
pnpm build && pnpm start, or (b) usepnpm dev. The fact that vitest was green proved nothing about the running API. Treatpnpm startas a deploy-mode command, not a dev-loop command.
2026-04-08 — Sprint 6 — Edit fails on files you haven't Read, even ones you just created via migrate
- Sprint / area: S6 / migrations + tooling
- Symptom: Tried to add RLS policies to a freshly-generated
prisma migrate devmigration.sql file via Edit. Edit refused: "must Read first." Meanwhileprisma migrate devhad ALREADY applied the migration to the live DB without the RLS appended, leaving the newattachmentstable with RLS disabled in the running database. - Root cause: Two compounding issues. (1)
prisma migrate devapplies the migration immediately as it's generated, before I get a chance to inspect it. (2) The Edit tool requires a prior Read of any file in the session, even files Claude just observed appearing on disk via another command. - Fix: (1) Read+Edit the file to add RLS for future fresh-installs /
migrate resetruns. (2) Manually apply the missing RLS to the running database viadocker exec -i amass-postgres psql -U postgres -d amass_crm <<SQL ... SQLso the live state matches what the migration file now says. - Lesson: When using
prisma migrate devon a model that needs RLS, the RLS DDL must be inserted BEFORE the migration applies. Workflow:prisma migrate dev --create-only(generates the file but does NOT apply)- Read + Edit the migration to append
ALTER TABLE … ENABLE/FORCE RLS,CREATE POLICY, andGRANT … TO app_user prisma migrate dev(now applies the complete, RLS-aware migration) Without--create-onlyyou'll always be playing catch-up against the live DB.
- Sprint / area: S4 / importer / e2e tests
- Symptom: An e2e test that uploads a file with
.attach('file', path)AND expects a 403 fromRolesGuardfailed withError: write EPIPE. The other 32 tests passed; this one was the only one that combined a multipart body with a guard-level rejection. - Root cause: NestJS execution order is guards → interceptors → pipes → handler. With
@UseGuards(RolesGuard)plus@UseInterceptors(FileInterceptor(...)), the guard rejects the request and Nest writes the 403 response before multer (the FileInterceptor) starts reading the multipart body. The server then closes the socket while supertest is still streaming the file → broken pipe. - Fix: Drop the
.attach()from the test. Since the role check fires before the body is parsed, the test only needs to send the auth header and hit the route — no file required. Test assertsexpect(403)and the 403 path is exactly what we want to cover. - Lesson: Don't pair
.attach()(or any large body) with assertions that rely on a guard-level rejection. Either:- Verify the guard with a body-less request, OR
- Use a tiny in-memory buffer (
.attach('file', Buffer.from('a,b\n1,2\n'), 'tiny.csv')) so the entire body fits in the TCP send buffer before the server closes. Knowing the Nest pipeline order (guards → interceptors → pipes → handler → response interceptors → exception filters) prevents this whole class of "why did supertest die" bugs.
- Sprint / area: S4 / queue infra
- Symptom: Would have crashed at worker startup with:
Error: BullMQ: Your redis options maxRetriesPerRequest must be null. - Root cause: BullMQ v5 workers use blocking Redis commands (
BRPOPLPUSH, etc). ioredis defaultsmaxRetriesPerRequest: 20, which means a blocked command in flight gets aborted after 20 retries — incompatible with workers that intentionally block forever waiting for jobs. - Fix: Construct the connection explicitly in
QueueModule:Share that one connection across all queues viaconnection: new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null })
BullModule.forRootAsync. Don't let@nestjs/bullmqauto-create per-queue connections without that flag. - Lesson: The
maxRetriesPerRequest: nullconstraint is BullMQ-specific and not obvious from the @nestjs/bullmq docs. Always construct the ioredis client yourself for BullMQ — don't pass a URL string and hope the defaults work.
- Sprint / area: S4 / tooling
- Symptom:
error TS5102: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0.After upgrading to TS 6.0.2 the build wouldn't pass. - Failed attempts (don't repeat these):
moduleResolution: "node16"— requires explicit.jsextensions in every relative import, would force a refactor of everyimport { x } from './y'in the codebase.moduleResolution: "bundler"— incompatible withmodule: "commonjs"(NestJS requires CJS at runtime).ignoreDeprecations: "6.0"while still on TS 5.9.3 →error TS5103: Invalid value for '--ignoreDeprecations'(5.9 only accepts"5.0").
- Fix:
- Bumped
typescriptto^6.0.2in bothapps/api/package.jsonANDpackages/shared/package.json(workspace root must match per-package version or pnpm warns). - Added
"ignoreDeprecations": "6.0"toapps/api/tsconfig.jsonandpackages/shared/tsconfig.json. - TS 6 also tightened
tsconfig.build.json: it now requires explicitrootDir: "src"(was previously inferred) — re-added it. - Re-ran
pnpm --filter @amass/api prisma:generateso the generated client matches the new TS version's stricter typings.
- Bumped
- Lesson: When fixing a deprecation warning, upgrade the compiler before adding the silencer flag — older TS versions don't recognise newer values for
ignoreDeprecations. And when bumping a workspace tool like TypeScript, bump it in EVERY package.json that lists it as a devDep, not just the root.
- Sprint / area: S3 / monorepo / @amass/shared
- Symptom: Tests passed (vitest transpiles via SWC on the fly), but the production build crashed at runtime:
Cannot find module '/packages/shared/src/schemas/common'. Node tried to load the raw.tsfile imported fromindex.ts. - Root cause:
packages/shared/package.jsonhad"main": "src/index.ts". Vitest+SWC didn't care, but compiled NestJS code indist/does — Node loads the JS, sees the import from@amass/shared, and follows themainfield. Pointing main at a.tsfile means Node has nothing to execute. - Fix: Added
tsconfig.jsontopackages/shared,buildscript (tsc -p tsconfig.json), set"main": "dist/index.js"and"types": "dist/index.d.ts". Build the shared package before building the API (turbo^buildalready orders this correctly). - Lesson: Workspace packages used by built apps must compile to JS.
main: src/*.tsonly works when the consumer is also a TS-aware runtime (vitest, ts-node). The moment adist/build runs the consumer, Node sees raw TS and dies.
- Sprint / area: S2 / multi-tenant isolation
- Symptom: RLS policies +
ENABLE ROW LEVEL SECURITY+FORCE ROW LEVEL SECURITYwere all in place.SET LOCAL app.tenant_id = 'X'was being applied (verified viacurrent_setting). But cross-tenantSELECTstill returned all rows. Inserts with wrong tenantId viaWITH CHECKpolicy still succeeded. - Root cause: Postgres superusers and roles with
BYPASSRLSalways bypass RLS, even when the table hasFORCE ROW LEVEL SECURITYenabled. The defaultpostgresuser in thepgvector/pgvectorimage is a superuser. So all our policies were no-ops for the connection user. - Fix:
- Created a non-superuser role
app_user(NOLOGIN NOSUPERUSER NOBYPASSRLS) via migration20260407211000_app_role. Granted it CRUD on all tables + default privileges for future tables. - Inside
PrismaService.runWithTenant, addedSET LOCAL ROLE app_userimmediately afterSET LOCAL app.tenant_id. This drops privileges for the rest of the transaction; reverted automatically at COMMIT/ROLLBACK. Migrations still run aspostgres(need owner privileges for DDL), but data-plane queries run asapp_userand obey RLS.
- Created a non-superuser role
- Lesson: RLS in Postgres has THREE prerequisites and missing any one makes it silently a no-op:
ALTER TABLE ... ENABLE ROW LEVEL SECURITYALTER TABLE ... FORCE ROW LEVEL SECURITY(otherwise the table owner bypasses it)- The connection user must NOT be SUPERUSER and must NOT have BYPASSRLS
Always verify with:
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;If either ist, RLS is a lie.
- Sprint / area: S1 / NestJS bootstrap, tests
- Symptom: All NestJS controllers crashed with
TypeError: Cannot read properties of undefined (reading 'register')— service was undefined inside controller. Same crash in vitest e2e and when runningtsx src/main.ts. - Root cause: Both
esbuild(used by vitest) andtsxstrip TypeScript decorator metadata. NestJS DI relies onemitDecoratorMetadata(design:paramtypesreflection) to resolve constructor parameters. No metadata → DI silently injectsundefined. - Fix:
- Vitest: added
unplugin-swc+@swc/coreand configuredswc.vite({ jsc: { transform: { legacyDecorator: true, decoratorMetadata: true } } })invitest.config.ts. - Dev/runtime: do NOT use
tsxfor the API — usetsc(tsconfig.build.json) →node dist/main.js, ornest start --watch. Both emit metadata correctly.
- Vitest: added
- Lesson: Any TS project using NestJS / TypeORM / class-validator / typedi MUST use a transformer that emits decorator metadata. esbuild/tsx/swc-without-config will all break DI silently. Default to swc with
decoratorMetadata: trueor tsc. Never usetsxfor NestJS app code.
- Sprint / area: S1 / tests
- Symptom:
Failed to load url expresswhen vitest tried to compileauth.controller.ts. - Root cause:
@nestjs/platform-expressre-exports express types, but Vite (used by vitest) does its own module resolution and doesn't follow the chain. Express was a transitive dep, not inapps/api/package.json. - Fix:
pnpm --filter @amass/api add express @types/express. - Lesson: When vitest is used to test NestJS code, add
express+@types/expressas direct deps even though@nestjs/platform-expressalready pulls them in transitively.
- Sprint / area: S1 / build config
- Symptom:
File 'test/auth.e2e.spec.ts' is not under 'rootDir' 'src'. - Root cause:
rootDir: "src"conflicts with includingtest/files for type-checking. - Fix: Removed
rootDirfromtsconfig.json(used for typecheck/tests). Addedtsconfig.build.jsonthat excludestest/and*.spec.ts, used bytsc -p tsconfig.build.jsonfor the production build. - Lesson: Two-tsconfig pattern (
tsconfig.jsonfor IDE/tests,tsconfig.build.jsonfordist/) is the standard NestJS layout. Don't fight it.
- Sprint / area: S0 / repo skeleton
- Symptom: none yet — first commit.
- Root cause: n/a
- Fix: n/a
- Lesson: Decisions locked in S0 to remember:
- pgvector image (
pgvector/pgvector:pg16) instead of stockpostgres:16— extension is preinstalled, noCREATE EXTENSIONheadache later. - MinIO bucket auto-creation uses a one-shot
minio/mcsidecar (minio-init). It exits after running and isrestart: "no"— that's intentional, do NOT change tounless-stoppedor it loops forever. - Caddyfile dev has
auto_https off— flipping this on locally hangs requests waiting for ACME. - Dockerfiles for api/web include placeholder build fallbacks (
|| true,|| echo …) sodocker compose buildworks even before real source exists. Remove these fallbacks once Sprint 1 lands realmain.ts/vite buildoutputs — otherwise build failures will be silently masked. - Compose env file: must be invoked as
docker compose -f infra/docker-compose.yml --env-file .env upfrom the repo root, OR viapnpm docker:up(which currently does NOT pass--env-file; defaults in compose cover dev). When real secrets land, switch the npm script to pass--env-file ../.envexplicitly. prisma generatein API Dockerfile is wrapped in|| truebecause the schema has no models in S0. Remove the|| trueonce Sprint 1 adds the first model.
- pgvector image (
- Sprint / area: Launch-blocker D-batch (test coverage + backup + observability + GDPR + VPS deploy + B-epic foundations)
- Symptom: A 4-agent parallel implementation pipeline shipped ~18 commits in one session. Two real bugs slipped past Layer-1 coding agents and Layer-2 reviewers, caught only by Layer-3 cross-cutting audit:
c357962(B2-PR1 WebAuthn) used@Inject(WEBAUTHN_ENV) env?: Envwithout@Optional(). Nest does NOT silently provideundefinedfor unregistered tokens — it throws "Can't resolve dependencies" at module bootstrap. Unit specs masked this because they always provided the token explicitly; only e2e bootstrap exposed it. Fixed inb908e40.5278c8e(D2-PR1 backup) Dockerfile installed crontab at/etc/crontabs/rootand rancrond -f -d 8without-c. BusyBox crond on Alpine 3.20 reads from/var/spool/cron/crontabs/<user>by default — the job would have silently never fired. Fixed ine639accby installing in BOTH locations + explicit-c /etc/crontabs.2052803(D2-PR2 metrics) registeredauth_login_totalcounter butAuthServicenever calledrecordAuthLogin(). Dead metric. Fixed ine639accby wiring 5 call-sites.
- Root cause: Layer-1 coding agents optimise for "compiles + spec passes". Unit specs that mock the failing dependency mask runtime bootstrap failures. BusyBox-style daemon defaults vary between docs and binaries. Defined-but-not-emitted metrics look correct in code review.
- Fix: Layer-2 verification with independent execution (not just code review) caught all three. Specifically:
- Run the actual e2e suite or at least
nest start --bootstrap-onlyto catch DI failures. - Boot the actual container to confirm cron fires once at a 1-min test interval.
- Diff the metric registration list against the call-site list to catch dead metrics.
- Run the actual e2e suite or at least
- Lesson: Multi-agent code production needs a third layer of runtime verification, not just static review.
tsc + lint + unit specs passingis necessary but not sufficient. Most expensive bugs in this session were dynamic (DI, cron defaults, missing call-site) — caught only by Layer-3 cross-cutting audit that grep'd for call-sites of newly-defined metrics, ran the actual e2e suite, and inspected daemon defaults. Pipeline now hardened: every multi-agent batch should end with a Layer-3 audit that runs ≥1 dynamic check (e2e bootstrap, container start, scrape).
- Sprint / area: D-batch / metrics observability
- Symptom:
Nest can't resolve dependencies of the BackupHealthService (?). Please make sure that the argument "PROM_METRIC_BACKUP_LAST_SUCCESS_TIMESTAMP_SECONDS" at index [0] is available in the HealthModule module.DespiteMetricsModulebeing@Global(). - Root cause:
@Global()only re-exports what's listed in the module'sexportsarray. Provider objects with string tokens (like those from@willsoto/nestjs-prometheus'smakeCounterProvider/makeGaugeProvider) need to be explicitly inexportsto cross module boundaries — even if their wrapping module is global. - Fix: Extract metric providers into a
metricProvidersarray, spread it into BOTHprovidersANDexports. Nest accepts Provider objects inexportsand extracts their tokens. Commit97f2e1a. - Lesson:
@Global()is necessary but not sufficient for cross-module string-token providers. Always export them by reference. The default behaviour is confusing because class providers (likeBusinessMetricsService) work either way — only string-tokens require explicit export.
- Sprint / area: D3 deploy
- Symptom: A whole set of
railway.tomlfiles +RAILWAY_DEPLOY.md+check-railway-readiness.shlanded in commit6561bdd, then got reverted 30 minutes later in2c14ee1. - Root cause:
CLAUDE.mdline 79 explicitly names "Docker compose · Caddy" as the canonical infra.scripts/bootstrap-vps.shalready did the entire deploy. I introduced Railway as a speculative target without checking what the user was actually using. - Fix: Reverted everything Railway-related (7 files deleted), pivoted D3 to D3-VPS: hardened
scripts/update-vps.shwith pre-update backup + health check + auto-rollback (ec0c1fc), addedscripts/check-prod-env.shvalidator (96e39ed), wired Prometheus alerts (3680f42). - Lesson: Before introducing a new deploy target, grep the existing
infra/,scripts/, andCLAUDE.mdfor the canonical one. The repo had already chosen its path; adding a parallel option created confusion, not options.
- Sprint / area: B3-PR1 SCIM service implementation
- Symptom: 14 TypeScript errors:
Type 'TransactionClient' is not assignable to type 'never'andProperty 'user' does not exist on type 'never'. Cut off a coding agent mid-implementation. - Root cause: Annotating the
txparameter explicitly (e.g.(tx: PrismaClient) => ...) fights the generic inference inrunWithTenant<T>(tenantId, fn). Type checker collapses tonever. - Fix: Let TS infer
tx. Pass an unannotated arrow function. The shape matches the Prisma transaction client automatically. Commit89b42ca. - Lesson: For generic functions where the type parameter is inferred from the body, never annotate the inferred parameter. If you need a return-type hint, annotate the OUTER call, not the inner callback.