create-rigel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/cli.js +122 -0
  4. package/package.json +47 -0
  5. package/templates/express/.claude/CLAUDE.md +114 -0
  6. package/templates/express/.claude/agents/arch-validator.md +85 -0
  7. package/templates/express/.claude/agents/db-optimizer.md +72 -0
  8. package/templates/express/.claude/agents/doc-gardener.md +67 -0
  9. package/templates/express/.claude/agents/garbage-collector.md +94 -0
  10. package/templates/express/.claude/agents/gate-checker.md +176 -0
  11. package/templates/express/.claude/agents/reviewer.md +84 -0
  12. package/templates/express/.claude/agents/security-auditor.md +82 -0
  13. package/templates/express/.claude/hooks/post-write.sh +93 -0
  14. package/templates/express/.claude/rules/api.md +149 -0
  15. package/templates/express/.claude/rules/architecture.md +78 -0
  16. package/templates/express/.claude/rules/database.md +104 -0
  17. package/templates/express/.claude/rules/jobs.md +81 -0
  18. package/templates/express/.claude/rules/security.md +92 -0
  19. package/templates/express/.claude/rules/testing.md +97 -0
  20. package/templates/express/.claude/settings.json +35 -0
  21. package/templates/express/.claude/skills/00-infra-setup/SKILL.md +479 -0
  22. package/templates/express/.claude/skills/01-write-roadmap/SKILL.md +72 -0
  23. package/templates/express/.claude/skills/02-write-spec/SKILL.md +119 -0
  24. package/templates/express/.claude/skills/03-write-plan/SKILL.md +124 -0
  25. package/templates/express/.claude/skills/04-build-layer/SKILL.md +216 -0
  26. package/templates/express/.claude/skills/05-validate-layer/SKILL.md +43 -0
  27. package/templates/express/.claude/skills/06-push-layer/SKILL.md +59 -0
  28. package/templates/express/.claude/skills/07-layer-check/SKILL.md +62 -0
  29. package/templates/express/.claude/skills/08-garbage-collect/SKILL.md +86 -0
  30. package/templates/express/.claude/skills/09-doc-garden/SKILL.md +64 -0
  31. package/templates/express/.claude/skills/10-db-optimize/SKILL.md +96 -0
  32. package/templates/express/.claude/skills/11-load-test/SKILL.md +77 -0
  33. package/templates/express/.dockerignore +43 -0
  34. package/templates/express/.env.example +42 -0
  35. package/templates/express/.github/CODEOWNERS +22 -0
  36. package/templates/express/.github/dependabot.yml +48 -0
  37. package/templates/express/.github/pull_request_template.md +58 -0
  38. package/templates/express/.github/workflows/ci.yml +278 -0
  39. package/templates/express/.github/workflows/load-test.yml +41 -0
  40. package/templates/express/.gitleaks.toml +18 -0
  41. package/templates/express/.lintstagedrc.json +3 -0
  42. package/templates/express/.nvmrc +1 -0
  43. package/templates/express/.prettierrc +8 -0
  44. package/templates/express/.sequelizerc +8 -0
  45. package/templates/express/AGENTS.md +94 -0
  46. package/templates/express/ARCHITECTURE.md +161 -0
  47. package/templates/express/Dockerfile +40 -0
  48. package/templates/express/Makefile +79 -0
  49. package/templates/express/QUICKSTART.md +131 -0
  50. package/templates/express/docker-compose.yml +114 -0
  51. package/templates/express/docs/PLANS.md +111 -0
  52. package/templates/express/docs/QUALITY_SCORE.md +66 -0
  53. package/templates/express/docs/design-docs/core-beliefs.md +89 -0
  54. package/templates/express/docs/design-docs/decisions/ADR-000-infrastructure.md +70 -0
  55. package/templates/express/docs/design-docs/decisions/ADR-001-observability.md +82 -0
  56. package/templates/express/docs/design-docs/decisions/index.md +23 -0
  57. package/templates/express/docs/design-docs/index.md +11 -0
  58. package/templates/express/docs/design-docs/observability.md +194 -0
  59. package/templates/express/docs/design-docs/team-workflow.md +64 -0
  60. package/templates/express/docs/exec-plans/active/.gitkeep +0 -0
  61. package/templates/express/docs/exec-plans/completed/.gitkeep +0 -0
  62. package/templates/express/docs/exec-plans/tech-debt-tracker.md +31 -0
  63. package/templates/express/docs/generated/.gitkeep +0 -0
  64. package/templates/express/docs/product-specs/ROADMAP.md +49 -0
  65. package/templates/express/docs/product-specs/draft/.gitkeep +0 -0
  66. package/templates/express/docs/product-specs/index.md +21 -0
  67. package/templates/express/docs/product-specs/ready/.gitkeep +0 -0
  68. package/templates/express/eslint.config.mjs +94 -0
  69. package/templates/express/gitignore +36 -0
  70. package/templates/express/infra/monitoring/alloy/config.alloy +59 -0
  71. package/templates/express/jest.config.ts +61 -0
  72. package/templates/express/package.json +45 -0
  73. package/templates/express/scripts/openapi.export.ts +77 -0
  74. package/templates/express/tests/architecture/isolation.test.ts +51 -0
  75. package/templates/express/tests/architecture/layers.test.ts +83 -0
  76. package/templates/express/tests/integration/health.test.ts +40 -0
  77. package/templates/express/tests/integration/isolation.test.template.ts +88 -0
  78. package/templates/express/tests/load/smoke.js +46 -0
  79. package/templates/express/tests/load/soak.js +45 -0
  80. package/templates/express/tests/load/stress.js +44 -0
  81. package/templates/express/tests/unit/utils/response.util.test.ts +53 -0
  82. package/templates/express/tsconfig.build.json +8 -0
  83. package/templates/express/tsconfig.json +20 -0
  84. package/templates/fastapi/.claude/CLAUDE.md +122 -0
  85. package/templates/fastapi/.claude/agents/arch-validator.md +64 -0
  86. package/templates/fastapi/.claude/agents/db-optimizer.md +59 -0
  87. package/templates/fastapi/.claude/agents/doc-gardener.md +55 -0
  88. package/templates/fastapi/.claude/agents/garbage-collector.md +74 -0
  89. package/templates/fastapi/.claude/agents/gate-checker.md +133 -0
  90. package/templates/fastapi/.claude/agents/reviewer.md +74 -0
  91. package/templates/fastapi/.claude/agents/security-auditor.md +69 -0
  92. package/templates/fastapi/.claude/hooks/post-write.sh +65 -0
  93. package/templates/fastapi/.claude/rules/api.md +119 -0
  94. package/templates/fastapi/.claude/rules/architecture.md +84 -0
  95. package/templates/fastapi/.claude/rules/database.md +153 -0
  96. package/templates/fastapi/.claude/rules/jobs.md +85 -0
  97. package/templates/fastapi/.claude/rules/security.md +102 -0
  98. package/templates/fastapi/.claude/rules/testing.md +207 -0
  99. package/templates/fastapi/.claude/settings.json +50 -0
  100. package/templates/fastapi/.claude/skills/00-infra-setup/SKILL.md +311 -0
  101. package/templates/fastapi/.claude/skills/01-write-roadmap/SKILL.md +61 -0
  102. package/templates/fastapi/.claude/skills/02-write-spec/SKILL.md +82 -0
  103. package/templates/fastapi/.claude/skills/03-write-plan/SKILL.md +83 -0
  104. package/templates/fastapi/.claude/skills/04-build-layer/SKILL.md +170 -0
  105. package/templates/fastapi/.claude/skills/05-validate-layer/SKILL.md +20 -0
  106. package/templates/fastapi/.claude/skills/06-push-layer/SKILL.md +31 -0
  107. package/templates/fastapi/.claude/skills/07-layer-check/SKILL.md +51 -0
  108. package/templates/fastapi/.claude/skills/08-garbage-collect/SKILL.md +47 -0
  109. package/templates/fastapi/.claude/skills/09-doc-garden/SKILL.md +9 -0
  110. package/templates/fastapi/.claude/skills/10-db-optimize/SKILL.md +53 -0
  111. package/templates/fastapi/.claude/skills/11-load-test/SKILL.md +81 -0
  112. package/templates/fastapi/.dockerignore +30 -0
  113. package/templates/fastapi/.github/dependabot.yml +24 -0
  114. package/templates/fastapi/AGENTS.md +87 -0
  115. package/templates/fastapi/ARCHITECTURE.md +141 -0
  116. package/templates/fastapi/Makefile +42 -0
  117. package/templates/fastapi/README.md +179 -0
  118. package/templates/fastapi/docs/PLANS.md +26 -0
  119. package/templates/fastapi/docs/QUALITY_SCORE.md +26 -0
  120. package/templates/fastapi/docs/design-docs/core-beliefs.md +46 -0
  121. package/templates/fastapi/docs/design-docs/decisions/ADR-000-infrastructure.md +79 -0
  122. package/templates/fastapi/docs/design-docs/decisions/index.md +7 -0
  123. package/templates/fastapi/docs/design-docs/index.md +9 -0
  124. package/templates/fastapi/docs/exec-plans/active/.gitkeep +0 -0
  125. package/templates/fastapi/docs/exec-plans/completed/.gitkeep +0 -0
  126. package/templates/fastapi/docs/exec-plans/tech-debt-tracker.md +19 -0
  127. package/templates/fastapi/docs/generated/.gitkeep +0 -0
  128. package/templates/fastapi/docs/product-specs/ROADMAP.md +49 -0
  129. package/templates/fastapi/docs/product-specs/draft/.gitkeep +0 -0
  130. package/templates/fastapi/docs/product-specs/index.md +18 -0
  131. package/templates/fastapi/docs/product-specs/ready/.gitkeep +0 -0
  132. package/templates/fastapi/gitignore +46 -0
  133. package/templates/fastapi/scripts/gate.sh +68 -0
  134. package/templates/nestjs/.claude/CLAUDE.md +121 -0
  135. package/templates/nestjs/.claude/agents/db-optimizer.md +51 -0
  136. package/templates/nestjs/.claude/agents/doc-gardener.md +45 -0
  137. package/templates/nestjs/.claude/agents/garbage-collector.md +50 -0
  138. package/templates/nestjs/.claude/agents/gate-checker.md +127 -0
  139. package/templates/nestjs/.claude/agents/reviewer.md +68 -0
  140. package/templates/nestjs/.claude/agents/security-auditor.md +72 -0
  141. package/templates/nestjs/.claude/hooks/post-write.sh +91 -0
  142. package/templates/nestjs/.claude/rules/api.md +87 -0
  143. package/templates/nestjs/.claude/rules/architecture.md +104 -0
  144. package/templates/nestjs/.claude/rules/database.md +111 -0
  145. package/templates/nestjs/.claude/rules/security.md +110 -0
  146. package/templates/nestjs/.claude/rules/testing.md +145 -0
  147. package/templates/nestjs/.claude/settings.json +33 -0
  148. package/templates/nestjs/.claude/skills/00-infra-setup/SKILL.md +222 -0
  149. package/templates/nestjs/.claude/skills/01-write-spec/SKILL.md +25 -0
  150. package/templates/nestjs/.claude/skills/02-write-plan/SKILL.md +18 -0
  151. package/templates/nestjs/.claude/skills/03-build-layer/SKILL.md +242 -0
  152. package/templates/nestjs/.claude/skills/04-validate-layer/SKILL.md +4 -0
  153. package/templates/nestjs/.claude/skills/05-push-layer/SKILL.md +6 -0
  154. package/templates/nestjs/.claude/skills/06-layer-check/SKILL.md +12 -0
  155. package/templates/nestjs/.claude/skills/07-garbage-collect/SKILL.md +2 -0
  156. package/templates/nestjs/.claude/skills/08-doc-garden/SKILL.md +4 -0
  157. package/templates/nestjs/.claude/skills/09-api-sync/SKILL.md +36 -0
  158. package/templates/nestjs/.claude/skills/10-load-test/SKILL.md +29 -0
  159. package/templates/nestjs/.env.example +11 -0
  160. package/templates/nestjs/AGENTS.md +88 -0
  161. package/templates/nestjs/ARCHITECTURE.md +192 -0
  162. package/templates/nestjs/docs/PLANS.md +22 -0
  163. package/templates/nestjs/docs/QUALITY_SCORE.md +27 -0
  164. package/templates/nestjs/docs/design-docs/core-beliefs.md +18 -0
  165. package/templates/nestjs/docs/design-docs/decisions/ADR-000-infrastructure.md +58 -0
  166. package/templates/nestjs/docs/design-docs/decisions/index.md +7 -0
  167. package/templates/nestjs/docs/design-docs/index.md +7 -0
  168. package/templates/nestjs/docs/exec-plans/active/.gitkeep +0 -0
  169. package/templates/nestjs/docs/exec-plans/completed/.gitkeep +0 -0
  170. package/templates/nestjs/docs/exec-plans/tech-debt-tracker.md +16 -0
  171. package/templates/nestjs/docs/generated/.gitkeep +0 -0
  172. package/templates/nestjs/docs/product-specs/draft/.gitkeep +0 -0
  173. package/templates/nestjs/docs/product-specs/index.md +7 -0
  174. package/templates/nestjs/docs/product-specs/ready/.gitkeep +0 -0
  175. package/templates/nestjs/gitignore +8 -0
  176. package/templates/nextjs/.claude/CLAUDE.md +139 -0
  177. package/templates/nextjs/.claude/agents/arch-validator.md +66 -0
  178. package/templates/nextjs/.claude/agents/contract-checker.md +78 -0
  179. package/templates/nextjs/.claude/agents/doc-gardener.md +52 -0
  180. package/templates/nextjs/.claude/agents/garbage-collector.md +61 -0
  181. package/templates/nextjs/.claude/agents/gate-checker.md +129 -0
  182. package/templates/nextjs/.claude/agents/perf-auditor.md +109 -0
  183. package/templates/nextjs/.claude/agents/reviewer.md +79 -0
  184. package/templates/nextjs/.claude/agents/security-auditor.md +50 -0
  185. package/templates/nextjs/.claude/hooks/post-write.sh +121 -0
  186. package/templates/nextjs/.claude/rules/api-contract.md +166 -0
  187. package/templates/nextjs/.claude/rules/architecture.md +99 -0
  188. package/templates/nextjs/.claude/rules/components.md +122 -0
  189. package/templates/nextjs/.claude/rules/security.md +122 -0
  190. package/templates/nextjs/.claude/rules/testing.md +187 -0
  191. package/templates/nextjs/.claude/scripts/infra-setup.sh +550 -0
  192. package/templates/nextjs/.claude/settings.json +33 -0
  193. package/templates/nextjs/.claude/skills/00-infra-setup/SKILL.md +471 -0
  194. package/templates/nextjs/.claude/skills/01-write-roadmap/SKILL.md +77 -0
  195. package/templates/nextjs/.claude/skills/02-write-spec/SKILL.md +78 -0
  196. package/templates/nextjs/.claude/skills/03-write-plan/SKILL.md +91 -0
  197. package/templates/nextjs/.claude/skills/04-build-layer/SKILL.md +141 -0
  198. package/templates/nextjs/.claude/skills/05-validate-layer/SKILL.md +13 -0
  199. package/templates/nextjs/.claude/skills/06-push-layer/SKILL.md +20 -0
  200. package/templates/nextjs/.claude/skills/07-layer-check/SKILL.md +28 -0
  201. package/templates/nextjs/.claude/skills/08-garbage-collect/SKILL.md +14 -0
  202. package/templates/nextjs/.claude/skills/09-doc-garden/SKILL.md +12 -0
  203. package/templates/nextjs/.claude/skills/10-api-sync/SKILL.md +50 -0
  204. package/templates/nextjs/.claude/skills/11-perf-budget/SKILL.md +51 -0
  205. package/templates/nextjs/.dockerignore +25 -0
  206. package/templates/nextjs/.gitattributes +10 -0
  207. package/templates/nextjs/.github/CODEOWNERS +24 -0
  208. package/templates/nextjs/.github/dependabot.yml +31 -0
  209. package/templates/nextjs/.github/pull_request_template.md +59 -0
  210. package/templates/nextjs/.github/workflows/ci.yml +143 -0
  211. package/templates/nextjs/.github/workflows/lighthouse.yml +54 -0
  212. package/templates/nextjs/.github/workflows/load-test.yml +54 -0
  213. package/templates/nextjs/.husky/pre-commit +1 -0
  214. package/templates/nextjs/.lintstagedrc.json +4 -0
  215. package/templates/nextjs/.mcp.json +8 -0
  216. package/templates/nextjs/.prettierignore +13 -0
  217. package/templates/nextjs/.prettierrc +9 -0
  218. package/templates/nextjs/AGENTS.md +88 -0
  219. package/templates/nextjs/ARCHITECTURE.md +165 -0
  220. package/templates/nextjs/Dockerfile +38 -0
  221. package/templates/nextjs/Makefile +50 -0
  222. package/templates/nextjs/QUICKSTART.md +128 -0
  223. package/templates/nextjs/docs/PLANS.md +23 -0
  224. package/templates/nextjs/docs/QUALITY_SCORE.md +27 -0
  225. package/templates/nextjs/docs/design-docs/core-beliefs.md +43 -0
  226. package/templates/nextjs/docs/design-docs/decisions/ADR-000-infrastructure.md +72 -0
  227. package/templates/nextjs/docs/design-docs/decisions/index.md +7 -0
  228. package/templates/nextjs/docs/design-docs/index.md +7 -0
  229. package/templates/nextjs/docs/design-docs/team-workflow.md +66 -0
  230. package/templates/nextjs/docs/exec-plans/active/.gitkeep +0 -0
  231. package/templates/nextjs/docs/exec-plans/completed/.gitkeep +0 -0
  232. package/templates/nextjs/docs/exec-plans/tech-debt-tracker.md +19 -0
  233. package/templates/nextjs/docs/generated/.gitkeep +0 -0
  234. package/templates/nextjs/docs/product-specs/ROADMAP.md +59 -0
  235. package/templates/nextjs/docs/product-specs/draft/.gitkeep +0 -0
  236. package/templates/nextjs/docs/product-specs/index.md +18 -0
  237. package/templates/nextjs/docs/product-specs/ready/.gitkeep +0 -0
  238. package/templates/nextjs/eslint.config.mjs +150 -0
  239. package/templates/nextjs/gitignore +35 -0
  240. package/templates/nextjs/lighthouserc.js +37 -0
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # .claude/hooks/post-write.sh
3
+ # Fires after every Write/Edit on Python files.
4
+ # Warns on: print(), os.environ, 400-line limit, raw dict cast on DB results.
5
+
6
+ set -euo pipefail
7
+
8
+ FILE=$(cat | python3 -c "
9
+ import sys, json
10
+ try:
11
+ data = json.load(sys.stdin)
12
+ print(data.get('file_path') or data.get('path') or '')
13
+ except:
14
+ print('')
15
+ " 2>/dev/null || echo "")
16
+
17
+ [[ -z "$FILE" ]] && exit 0
18
+ [[ ! -f "$FILE" ]] && exit 0
19
+ [[ ! "$FILE" =~ \.py$ ]] && exit 0
20
+
21
+ WARNINGS=()
22
+
23
+ # 1. print() in src/ — use structlog logger
24
+ if [[ "$FILE" =~ ^src/ ]] && [[ ! "$FILE" =~ src/providers/logger ]] && [[ ! "$FILE" =~ src/config/ ]]; then
25
+ if grep -qn "^\s*print(" "$FILE" 2>/dev/null; then
26
+ LINE=$(grep -n "^\s*print(" "$FILE" | head -1 | cut -d: -f1)
27
+ WARNINGS+=("⚠ [HOOK] print() at $FILE:$LINE — use logger from src/providers/logger.py")
28
+ fi
29
+ fi
30
+
31
+ # 2. os.environ outside config/settings.py
32
+ if [[ "$FILE" =~ ^src/ ]] && [[ ! "$FILE" =~ src/config/settings\.py ]]; then
33
+ if grep -qn "os\.environ" "$FILE" 2>/dev/null; then
34
+ LINE=$(grep -n "os\.environ" "$FILE" | head -1 | cut -d: -f1)
35
+ WARNINGS+=("⚠ [HOOK] os.environ at $FILE:$LINE — use settings from src/config/settings.py")
36
+ fi
37
+ fi
38
+
39
+ # 3. File size > 400 lines
40
+ LINE_COUNT=$(wc -l < "$FILE" 2>/dev/null || echo 0)
41
+ if [[ $LINE_COUNT -gt 400 ]]; then
42
+ WARNINGS+=("⚠ [HOOK] $FILE has $LINE_COUNT lines — exceeds 400-line limit. Split into focused modules.")
43
+ fi
44
+
45
+ # 4. Unsafe DB result cast in repo files
46
+ if [[ "$FILE" =~ _repo\.py$ ]]; then
47
+ if grep -qn "return row\.__dict__\b\|return dict(row" "$FILE" 2>/dev/null; then
48
+ LINE=$(grep -n "return row\.__dict__\|return dict(row" "$FILE" | head -1 | cut -d: -f1)
49
+ WARNINGS+=("⚠ [HOOK] Unsafe cast at $FILE:$LINE — use Schema.model_validate(row.__dict__)")
50
+ fi
51
+ fi
52
+
53
+ # 5. HTTPException in service files
54
+ if [[ "$FILE" =~ _service\.py$ ]]; then
55
+ if grep -qn "HTTPException\|raise HTTPException" "$FILE" 2>/dev/null; then
56
+ LINE=$(grep -n "HTTPException" "$FILE" | head -1 | cut -d: -f1)
57
+ WARNINGS+=("⚠ [HOOK] HTTPException at $FILE:$LINE — services must raise DomainError, not HTTPException")
58
+ fi
59
+ fi
60
+
61
+ for w in "${WARNINGS[@]}"; do
62
+ echo "$w"
63
+ done
64
+
65
+ exit 0
@@ -0,0 +1,119 @@
1
+ ---
2
+ paths:
3
+ - "src/runtime/routers/**/*.py"
4
+ ---
5
+
6
+ # API Rules — Auto-injected on router file edits
7
+
8
+ ## Every Route File Checklist
9
+
10
+ ### Versioning
11
+ ```python
12
+ # ✅ All routers under version prefix
13
+ router = APIRouter(prefix="/applications", tags=["applications"])
14
+ # Mounted in main.py at: app.include_router(router, prefix="/api/v1")
15
+
16
+ # ❌ Never create unversioned routes
17
+ app.include_router(router) # no version prefix
18
+ ```
19
+
20
+ ### Required Handler Structure
21
+ ```python
22
+ @router.post("/", status_code=status.HTTP_201_CREATED)
23
+ async def create_application(
24
+ body: CreateApplicationInput, # 1. Pydantic validates body
25
+ auth: AuthContext = Depends(require_auth), # 2. Auth
26
+ session: AsyncSession = Depends(get_session), # 3. DB session
27
+ _: None = Depends(user_rate_limit), # 4. Rate limit
28
+ ) -> ApplicationResponse: # 5. Typed return
29
+ result = await application_service.create(session, auth.user_id, body)
30
+ return result # 6. FastAPI serialises
31
+ # Errors → global exception handlers → no try/except here
32
+ ```
33
+
34
+ ### Rate Limit Tiers
35
+ ```python
36
+ # In src/providers/rate_limit.py — three tiers:
37
+ auth_rate_limit = Depends(...) # 10/min — login, register, reset
38
+ public_rate_limit = Depends(...) # 60/min — unauthenticated reads
39
+ user_rate_limit = Depends(...) # 300/min — authenticated endpoints
40
+
41
+ # Apply at router level for all routes in a router:
42
+ router = APIRouter(dependencies=[Depends(user_rate_limit)])
43
+ ```
44
+
45
+ ### Response Envelope — Always
46
+ ```python
47
+ # ✅ Wrap all responses in SuccessEnvelope
48
+ from src.runtime.response_util import ok # builders live in runtime (utils may not import types)
49
+ from src.types.common import SuccessEnvelope
50
+
51
+ @router.get("/{app_id}")
52
+ async def get_application(
53
+ app_id: UUID,
54
+ request: Request, # for request_id
55
+ auth: AuthContext = Depends(require_auth),
56
+ session: AsyncSession = Depends(get_session),
57
+ ) -> SuccessEnvelope[ApplicationResponse]:
58
+ result = await application_service.get(session, auth.user_id, app_id)
59
+ return ok(result, request.state.request_id)
60
+ # Response: { "ok": true, "data": {...}, "meta": { "request_id": "...", "timestamp": "..." } }
61
+
62
+ # ✅ For lists — use PageResult wrapped in envelope
63
+ @router.get("/")
64
+ async def list_applications(
65
+ request: Request,
66
+ ...
67
+ ) -> SuccessEnvelope[PageResult[ApplicationResponse]]:
68
+ result = await application_service.list(session, auth.user_id, cursor, limit)
69
+ return ok(result, request.state.request_id)
70
+ # Response: { "ok": true, "data": { "items": [...], "next_cursor": "...", "has_more": true }, "meta": {...} }
71
+
72
+ # ❌ Never return bare Pydantic models
73
+ @router.get("/{app_id}")
74
+ async def get_application(...) -> ApplicationResponse: # FORBIDDEN
75
+ return await application_service.get(...) # missing envelope
76
+ ```
77
+
78
+ **Error envelope** (handled by global exception handler):
79
+ ```python
80
+ # Errors automatically wrapped by exception_handlers.py:
81
+ # { "ok": false, "error": { "code": "NOT_FOUND", "message": "..." }, "meta": {...} }
82
+ ```
83
+
84
+ ### Pagination Query Params — Opaque Cursor
85
+ ```python
86
+ from src.utils.cursor_util import decode_cursor, encode_cursor
87
+
88
+ @router.get("/")
89
+ async def list_applications(
90
+ cursor: str | None = Query(None), # opaque base64url-encoded cursor
91
+ limit: int = Query(default=20, ge=1, le=100),
92
+ auth: AuthContext = Depends(require_auth),
93
+ session: AsyncSession = Depends(get_session),
94
+ ) -> SuccessEnvelope[PageResult[ApplicationResponse]]:
95
+ decoded_cursor = decode_cursor(cursor) if cursor else None
96
+ # decoded_cursor = {"id": uuid, "created_at": iso_str} or None
97
+ result = await application_service.list(session, auth.user_id, decoded_cursor, limit)
98
+ return ok(result, request.state.request_id)
99
+
100
+ # ❌ Never expose dual cursor params (leaks implementation details)
101
+ @router.get("/")
102
+ async def list_applications(
103
+ cursor_id: UUID | None = Query(None), # FORBIDDEN
104
+ cursor_created_at: datetime | None = Query(None), # FORBIDDEN
105
+ ...
106
+ ```
107
+
108
+ **Cursor encoding** (in service layer):
109
+ ```python
110
+ # Service returns PageResult with next_cursor as opaque string
111
+ from src.utils.cursor_util import encode_cursor
112
+
113
+ next_cursor = encode_cursor({"id": str(last_item.id), "created_at": last_item.created_at.isoformat()})
114
+ return PageResult(items=items, next_cursor=next_cursor, has_more=has_more)
115
+ ```
116
+
117
+ ### Security Headers
118
+ Applied globally in `main.py` via middleware — never per-route.
119
+ Verify `SecurityHeadersMiddleware` is in the middleware stack.
@@ -0,0 +1,84 @@
1
+ ---
2
+ paths:
3
+ - "src/**/*.py"
4
+ ---
5
+
6
+ # Architecture Rules — Auto-injected on every src/ file edit
7
+
8
+ ## Import Order Enforcement
9
+
10
+ Before writing any import, check which layer you are in:
11
+
12
+ | Your file is in | You may import from |
13
+ |---|---|
14
+ | `src/types/` | Nothing (third-party ok: pydantic, enum) |
15
+ | `src/config/` | `src/types/` only |
16
+ | `src/models/` | `src/types/`, `src/config/` |
17
+ | `src/repo/` | `src/types/`, `src/config/`, `src/models/` |
18
+ | `src/services/` | `src/types/`, `src/config/`, `src/repo/` |
19
+ | `src/runtime/` | `src/types/`, `src/config/`, `src/repo/`, `src/services/` |
20
+ | `src/utils/` | Nothing (stdlib + third-party only) |
21
+ | `src/providers/` | `src/types/`, `src/config/` only |
22
+
23
+ ## Correct Patterns Per Layer
24
+
25
+ ### Types (`src/types/`)
26
+ ```python
27
+ # ✅ Pure Pydantic schema — no imports from other layers
28
+ from pydantic import BaseModel
29
+ from enum import Enum
30
+
31
+ class ApplicationStage(str, Enum):
32
+ SAVED = "SAVED"
33
+ APPLIED = "APPLIED"
34
+
35
+ class ApplicationResponse(BaseModel):
36
+ id: str
37
+ stage: ApplicationStage
38
+
39
+ # ❌ Wrong
40
+ from src.config.settings import settings # FORBIDDEN in types
41
+ ```
42
+
43
+ ### Service (`src/services/`)
44
+ ```python
45
+ # ✅ Takes typed inputs, returns typed outputs, raises DomainError
46
+ async def create_application(
47
+ session: AsyncSession,
48
+ user_id: str,
49
+ data: CreateApplicationInput,
50
+ ) -> ApplicationResponse:
51
+ ...
52
+ raise ApplicationNotFoundError("...") # Domain error — ok
53
+
54
+ # ❌ Wrong
55
+ from fastapi import HTTPException # FORBIDDEN in service
56
+ raise HTTPException(status_code=404) # FORBIDDEN — only in runtime
57
+ ```
58
+
59
+ ### Runtime (`src/runtime/`)
60
+ ```python
61
+ # ✅ Correct handler structure — THIS ORDER always
62
+ @router.post("/applications", status_code=201)
63
+ async def create_application(
64
+ body: CreateApplicationInput, # 1. Pydantic validates
65
+ auth: AuthContext = Depends(require_auth), # 2. Auth
66
+ session: AsyncSession = Depends(get_session), # 3. DB session
67
+ ) -> ApplicationResponse:
68
+ return await application_service.create(session, auth.user_id, body)
69
+ # error mapping handled by global exception handlers — not here
70
+
71
+ # ❌ Wrong — business logic in handler
72
+ @router.post("/applications")
73
+ async def create(body: dict, ...): # untyped body
74
+ if body["stage"] == "APPLIED": # logic in handler
75
+ ```
76
+
77
+ ## ruff Configuration
78
+ `ruff.toml` enforces:
79
+ - `no-print` (T20) — use structlog
80
+ - Import order (I) — enforced automatically
81
+ - No unused imports (F401)
82
+ - Type annotations required (ANN)
83
+
84
+ Run anytime: `uv run ruff check src/`
@@ -0,0 +1,153 @@
1
+ ---
2
+ paths:
3
+ - "src/repo/**/*.py"
4
+ - "src/models/**/*.py"
5
+ - "alembic/versions/**/*.py"
6
+ ---
7
+
8
+ # Database Rules — Auto-injected on repo/model/migration edits
9
+
10
+ ## The 5 Checks Before Every Repo Method Ships
11
+
12
+ ### 1. Pydantic Validation on Every Result
13
+ ```python
14
+ # ✅ REQUIRED
15
+ row = result.scalar_one_or_none()
16
+ if not row:
17
+ raise ApplicationNotFoundError(...)
18
+ return ApplicationResponse.model_validate(row.__dict__)
19
+
20
+ # ❌ FORBIDDEN
21
+ return row.__dict__ # raw, unvalidated
22
+ return dict(row.__dict__) # cast, not validated
23
+ return row # ORM object leaking out of repo
24
+ ```
25
+
26
+ ### 2. No N+1 — Eager Load Always
27
+ ```python
28
+ # ✅ selectinload for related collections
29
+ stmt = (
30
+ select(Application)
31
+ .where(Application.user_id == user_id)
32
+ .options(
33
+ selectinload(Application.notes),
34
+ selectinload(Application.contacts),
35
+ )
36
+ )
37
+
38
+ # ❌ N+1 — async loop over lazy loads
39
+ apps = (await session.execute(select(Application))).scalars().all()
40
+ for app in apps:
41
+ notes = await session.execute(select(Note).where(Note.application_id == app.id))
42
+ ```
43
+
44
+ ### 3. Cursor Pagination on All List Methods
45
+
46
+ `PageResult` has ONE shape across the whole stack — `{ items, next_cursor, has_more }` where
47
+ `next_cursor` is the OPAQUE base64url token produced by `encode_cursor`. The repo decodes the
48
+ incoming token, keyset-paginates, and re-encodes the last row. Never expose `created_at`/`id`
49
+ cursor fields on the API or in `PageResult` (see `.claude/rules/api.md`).
50
+
51
+ **The cursor is adversarial input.** `decode_cursor` MUST catch malformed values
52
+ (`binascii.Error`, `json.JSONDecodeError`, `ValueError`, `UnicodeDecodeError`) and raise
53
+ `InvalidCursorError` (mapped to **400**). A raw `decode_cursor("null")` otherwise throws an
54
+ unhandled exception → **500**. (Found by schemathesis fuzzing `?cursor=null`.)
55
+
56
+ ```python
57
+ # ✅ Cursor-based — opaque token in, opaque token out
58
+ from src.utils.cursor_util import decode_cursor, encode_cursor
59
+
60
+ async def list_applications(
61
+ session: AsyncSession,
62
+ user_id: UUID,
63
+ cursor: str | None = None, # opaque base64url token (or None for first page)
64
+ limit: int = 20,
65
+ ) -> PageResult[ApplicationResponse]:
66
+ stmt = select(Application).where(Application.user_id == user_id, Application.deleted_at.is_(None))
67
+
68
+ decoded = decode_cursor(cursor) if cursor else None # {"created_at": iso_str, "id": uuid} | None
69
+ if decoded:
70
+ cursor_created_at = datetime.fromisoformat(decoded["created_at"])
71
+ cursor_id = UUID(decoded["id"])
72
+ stmt = stmt.where(
73
+ or_(
74
+ Application.created_at < cursor_created_at,
75
+ and_(Application.created_at == cursor_created_at, Application.id < cursor_id),
76
+ )
77
+ )
78
+
79
+ stmt = stmt.order_by(Application.created_at.desc(), Application.id.desc()).limit(limit + 1)
80
+ rows = (await session.execute(stmt)).scalars().all()
81
+
82
+ has_more = len(rows) > limit
83
+ items = rows[:limit]
84
+ next_cursor = (
85
+ encode_cursor({"created_at": items[-1].created_at.isoformat(), "id": str(items[-1].id)})
86
+ if has_more and items
87
+ else None
88
+ )
89
+ return PageResult(
90
+ items=[ApplicationResponse.model_validate(r.__dict__) for r in items],
91
+ next_cursor=next_cursor, # single opaque token — matches api.md + frontend contract
92
+ has_more=has_more,
93
+ )
94
+
95
+ # ❌ FORBIDDEN — offset on large tables
96
+ stmt = select(Application).offset(page * limit).limit(limit)
97
+
98
+ # ❌ FORBIDDEN — leaking dual cursor fields (next_cursor_created_at / next_cursor_id)
99
+ ```
100
+
101
+ ### 4. Transactions for Multi-Table Writes
102
+
103
+ Atomicity is orchestrated by the SERVICE, but every `session.add` / ORM construction stays
104
+ inside a repo method. The repo is the ONLY layer that touches ORM models — services never
105
+ import `src/models` (see `ARCHITECTURE.md` import rules).
106
+
107
+ ```python
108
+ # ✅ Service opens ONE transaction; repos do the ORM writes
109
+ # in application_service.create():
110
+ async with session.begin():
111
+ app = await application_repo.create(session, user_id, data)
112
+ await note_repo.create(session, app.id, content="Created", type="SYSTEM")
113
+ # commits on context exit, rolls back on exception
114
+
115
+ # ✅ Inside application_repo.create() — the repo owns ORM construction + validation:
116
+ async def create(session, user_id, data) -> ApplicationResponse:
117
+ row = Application(user_id=user_id, **data.model_dump())
118
+ session.add(row)
119
+ await session.flush()
120
+ return ApplicationResponse.model_validate(row.__dict__)
121
+
122
+ # ❌ Never construct ORM models or call session.add() in a service — that is the repo's job
123
+ # ❌ Never call external APIs inside a transaction block
124
+ ```
125
+
126
+ ### 5. Soft Delete Pattern
127
+ ```python
128
+ # ✅ Model has deleted_at — filter in every query
129
+ stmt = select(Application).where(
130
+ Application.user_id == user_id,
131
+ Application.deleted_at.is_(None) # always include this
132
+ )
133
+
134
+ # ✅ Soft delete (timezone-aware — datetime.utcnow() is deprecated in 3.12+)
135
+ row.deleted_at = datetime.now(UTC) # from datetime import UTC — ruff UP017 prefers UTC over timezone.utc
136
+ await session.flush()
137
+
138
+ # ❌ Hard delete — only for GDPR with explicit comment
139
+ await session.delete(row) # only if legally required
140
+ ```
141
+
142
+ ## Model Checklist (every new model)
143
+ - [ ] `deleted_at: Mapped[datetime | None]` — soft delete
144
+ - [ ] `id: Mapped[UUID]` with `default=uuid7` — time-ordered UUID
145
+ - [ ] `__table_args__` with indexes on FK + ORDER BY columns
146
+ - [ ] Partial index for `WHERE deleted_at IS NULL` queries
147
+ - [ ] `created_at`, `updated_at` with server defaults
148
+
149
+ ## Migration Checklist (every new migration)
150
+ - [ ] Run `alembic revision --autogenerate -m "description"` then review output
151
+ - [ ] New indexes use `postgresql_concurrently=True`
152
+ - [ ] Both `upgrade()` and `downgrade()` implemented
153
+ - [ ] Run `alembic upgrade head` to verify
@@ -0,0 +1,85 @@
1
+ ---
2
+ paths:
3
+ - "src/runtime/workers/**/*.py"
4
+ - "src/workers/**/*.py"
5
+ ---
6
+
7
+ # Job Rules — Auto-injected on worker file edits
8
+
9
+ ## Every Celery Task Checklist
10
+
11
+ ### 1. Validate payload with Pydantic before touching it
12
+ ```python
13
+ @celery_app.task(
14
+ name="reminder.send_email",
15
+ bind=True,
16
+ max_retries=3,
17
+ default_retry_delay=60,
18
+ queue="reminder-email",
19
+ )
20
+ def send_reminder_email_task(self: Task, payload: dict) -> None:
21
+ # ✅ REQUIRED — validate before processing
22
+ data = ReminderEmailPayload.model_validate(payload)
23
+
24
+ # ❌ FORBIDDEN — trusting raw task payload
25
+ reminder_id = payload["reminder_id"] # no validation
26
+ ```
27
+
28
+ ### 2. Structured logging — start, complete, failed
29
+ ```python
30
+ def send_reminder_email_task(self: Task, payload: dict) -> None:
31
+ data = ReminderEmailPayload.model_validate(payload)
32
+ start = time.time()
33
+
34
+ logger.info("job.start", task=self.name, task_id=self.request.id)
35
+
36
+ try:
37
+ reminder_service.send_email(data)
38
+ logger.info(
39
+ "job.complete",
40
+ task=self.name,
41
+ task_id=self.request.id,
42
+ duration_ms=int((time.time() - start) * 1000),
43
+ )
44
+ except Exception as exc:
45
+ logger.error("job.failed", task=self.name, err=str(exc))
46
+ raise self.retry(exc=exc, countdown=2 ** self.request.retries * 30)
47
+ ```
48
+
49
+ ### 3. Retry with exponential backoff
50
+ ```python
51
+ @celery_app.task(
52
+ bind=True,
53
+ max_retries=3,
54
+ acks_late=True, # ack after success, not on receipt
55
+ reject_on_worker_lost=True, # re-queue if worker dies mid-task
56
+ )
57
+ def my_task(self, payload: dict) -> None:
58
+ ...
59
+ except SomeTransientError as exc:
60
+ raise self.retry(exc=exc, countdown=2 ** self.request.retries * 30)
61
+ ```
62
+
63
+ ### 4. External calls use tenacity retry + timeout
64
+ ```python
65
+ from tenacity import retry, stop_after_attempt, wait_exponential
66
+ import httpx
67
+
68
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
69
+ async def call_email_provider(data: EmailPayload) -> None:
70
+ async with httpx.AsyncClient(timeout=10.0) as client:
71
+ response = await client.post(EMAIL_API_URL, json=data.model_dump())
72
+ response.raise_for_status()
73
+ ```
74
+
75
+ ### 5. Never create DB sessions inside a task directly
76
+ ```python
77
+ # ✅ Use the service layer which manages its own session
78
+ def send_reminder_email_task(self, payload: dict) -> None:
79
+ data = ReminderEmailPayload.model_validate(payload)
80
+ # Service opens its own session internally
81
+ asyncio.run(reminder_service.process(data))
82
+
83
+ # ❌ Never
84
+ async with AsyncSessionLocal() as session: # not thread-safe in Celery workers
85
+ ```
@@ -0,0 +1,102 @@
1
+ ---
2
+ paths:
3
+ - "src/runtime/**/*.py"
4
+ - "src/repo/**/*.py"
5
+ ---
6
+
7
+ # Security Rules — Auto-injected on runtime and repo file edits
8
+
9
+ ## Runtime: Every Protected Route Requires This Structure
10
+
11
+ ```python
12
+ @router.get("/{app_id}")
13
+ async def get_application(
14
+ app_id: UUID,
15
+ auth: AuthContext = Depends(require_auth), # 1. Auth FIRST
16
+ session: AsyncSession = Depends(get_session), # 2. DB session
17
+ _: None = Depends(user_rate_limit), # 3. Rate limit
18
+ ) -> ApplicationResponse:
19
+ # 4. Service call with domain types only
20
+ return await application_service.get(session, auth.user_id, app_id)
21
+ # 5. Errors handled by global exception handlers — not here
22
+ ```
23
+
24
+ ## Repo: Validation at Every Boundary
25
+
26
+ ```python
27
+ # ✅ REQUIRED — validate every DB result
28
+ async def find_by_id(session: AsyncSession, app_id: UUID, user_id: UUID) -> ApplicationResponse:
29
+ result = await session.execute(
30
+ select(Application).where(
31
+ Application.id == app_id,
32
+ Application.user_id == user_id, # ownership enforced HERE
33
+ Application.deleted_at.is_(None)
34
+ )
35
+ )
36
+ row = result.scalar_one_or_none()
37
+ if not row:
38
+ raise ApplicationNotFoundError(f"Application {app_id} not found")
39
+ return ApplicationResponse.model_validate(row.__dict__)
40
+
41
+ # ❌ FORBIDDEN — no ownership check
42
+ row = await session.get(Application, app_id) # anyone can access
43
+
44
+ # ❌ FORBIDDEN — raw dict returned
45
+ return row.__dict__
46
+ ```
47
+
48
+ ## SQL Injection Prevention
49
+
50
+ ```python
51
+ # ✅ SQLAlchemy ORM — always parameterised
52
+ await session.execute(select(Application).where(Application.user_id == user_id))
53
+
54
+ # ✅ Raw SQL with bound parameters
55
+ await session.execute(text("SELECT * FROM applications WHERE user_id = :uid"), {"uid": user_id})
56
+
57
+ # ❌ BLOCKED — string interpolation
58
+ await session.execute(text(f"SELECT * FROM applications WHERE user_id = '{user_id}'"))
59
+ ```
60
+
61
+ ## Auth — Always via Depends(), Never Inline
62
+
63
+ ```python
64
+ # ✅ Provider injected
65
+ from src.providers.auth.middleware import require_auth
66
+
67
+ @router.post("/")
68
+ async def create(auth: AuthContext = Depends(require_auth), ...):
69
+ ...
70
+
71
+ # ❌ Never roll your own inline
72
+ token = request.headers.get("Authorization")
73
+ payload = jwt.decode(token, os.environ["JWT_SECRET"]) # multiple violations (PyJWT used inline + os.environ)
74
+ ```
75
+
76
+ ## Error Responses — Never Expose Internals
77
+
78
+ ```python
79
+ # ✅ Global exception handler maps DomainError → HTTPException
80
+ # In src/runtime/exception_handlers.py:
81
+ @app.exception_handler(ApplicationNotFoundError)
82
+ async def not_found_handler(request, exc):
83
+ return JSONResponse(status_code=404, content={"error": {"code": "NOT_FOUND", "message": str(exc)}})
84
+
85
+ # ❌ Never in handlers
86
+ raise HTTPException(status_code=500, detail=str(exc)) # exposes internals
87
+ ```
88
+
89
+ ## Password Hashing — argon2id via pwdlib Only
90
+
91
+ ```python
92
+ # ✅ pwdlib with argon2id (passlib is BROKEN on Python 3.13+ — never use it)
93
+ from pwdlib import PasswordHash
94
+ password_hash = PasswordHash.recommended() # argon2id
95
+ hashed = password_hash.hash(plain_password)
96
+ verified = password_hash.verify(plain_password, hashed)
97
+
98
+ # ❌ Never — passlib (broken on 3.13+), bcrypt (GPU-vulnerable), or hashlib
99
+ from passlib.context import CryptContext # FORBIDDEN — broken on Python 3.13+
100
+ import hashlib
101
+ hashlib.md5(password.encode()).hexdigest() # FORBIDDEN
102
+ ```