You are a Senior Code Reviewer with expertise in Kubernetes and infrastructure-as-code. Review the service resources just committed.

## What Was Implemented

Task 4 of 6 for `churn-renewal-forecast` Kustomize deployment. Created: web Deployment (Next.js, `bun run start`, port 3000, liveness/readiness probes), worker Deployment (pg-boss queue consumer, `bunx tsx src/worker/index.ts`, no probes), ClusterIP Service (port 80→3000, selects web pods only), Nginx Ingress (churn-forecast.infrastructure.immuta.io, TLS, ingressClassName: nginx).

## Requirements / Plan

- web-deployment.yaml: replicas 1, image registry.ic-ops.net/churn-renewal-forecast:latest, command ["bun","run","start"], port 3000, envFrom configmap+secret, resources 256Mi/100m req, 512Mi/500m limits, liveness /api/health/live delay 15 period 20, readiness /api/health/ready delay 10 period 10
- worker-deployment.yaml: same image, command ["bunx","tsx","src/worker/index.ts"], no ports, no probes, same resources, envFrom
- service.yaml: ClusterIP, port 80→3000, selects component: web
- ingress.yaml: ingressClassName nginx, TLS churn-forecast.infrastructure.immuta.io, backend port name http

## Git Range to Review

**Base:** bcb824fe0570ccb8840df1114c902bd619a83724
**Head:** d99223125b583306d982d37167dc92fcb7351ee1

```bash
git diff --stat bcb824fe0570ccb8840df1114c902bd619a83724..d99223125b583306d982d37167dc92fcb7351ee1
git diff bcb824fe0570ccb8840df1114c902bd619a83724..d99223125b583306d982d37167dc92fcb7351ee1
```

Work from: `/Users/steve.briggs/projects/churn-renewal-forecast`

## Output Format

### Strengths
### Issues
#### Critical (Must Fix)
#### Important (Should Fix)
#### Minor (Nice to Have)
### Assessment
**Ready to merge?** [Yes | No | With fixes]
**Reasoning:**


- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- mind:stats: Show memory statistics and storage information
- mind:search: Search memories for specific content or patterns
- mind:ask: Ask questions about memories and get context-aware answers
- mind:recent: Show recent memories and activity timeline
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


I'll review the committed infrastructure files by examining the git diff.


Bash

Show changed files between commits


Show full diff between commits


 deploy/kubernetes/base/services/ingress.yaml       | 25 ++++++++++
 deploy/kubernetes/base/services/kustomization.yaml |  9 ++++
 deploy/kubernetes/base/services/service.yaml       | 18 ++++++++
 .../kubernetes/base/services/web-deployment.yaml   | 53 ++++++++++++++++++++++
 .../base/services/worker-deployment.yaml           | 38 ++++++++++++++++
 5 files changed, 143 insertions(+)


diff --git a/deploy/kubernetes/base/services/ingress.yaml b/deploy/kubernetes/base/services/ingress.yaml
new file mode 100644
index 00000000..5bba6237
--- /dev/null
+++ b/deploy/kubernetes/base/services/ingress.yaml
@@ -0,0 +1,25 @@
+# deploy/kubernetes/base/services/ingress.yaml
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: churn-renewal-forecast
+  namespace: churn-renewal-forecast
+  labels:
+    app.kubernetes.io/name: churn-renewal-forecast
+spec:
+  ingressClassName: nginx
+  tls:
+    - hosts:
+        - churn-forecast.infrastructure.immuta.io
+      secretName: churn-renewal-forecast-tls
+  rules:
+    - host: churn-forecast.infrastructure.immuta.io
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: churn-renewal-forecast
+                port:
+                  name: http
diff --git a/deploy/kubernetes/base/services/kustomization.yaml b/deploy/kubernetes/base/services/kustomization.yaml
new file mode 100644
index 00000000..9a58d9f9
--- /dev/null
+++ b/deploy/kubernetes/base/services/kustomization.yaml
@@ -0,0 +1,9 @@
+# deploy/kubernetes/base/services/kustomization.yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+  - web-deployment.yaml
+  - worker-deployment.yaml
+  - service.yaml
+  - ingress.yaml
diff --git a/deploy/kubernetes/base/services/service.yaml b/deploy/kubernetes/base/services/service.yaml
new file mode 100644
index 00000000..17b6c46e
--- /dev/null
+++ b/deploy/kubernetes/base/services/service.yaml
@@ -0,0 +1,18 @@
+# deploy/kubernetes/base/services/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: churn-renewal-forecast
+  namespace: churn-renewal-forecast
+  labels:
+    app.kubernetes.io/name: churn-renewal-forecast
+    app.kubernetes.io/component: web
+spec:
+  type: ClusterIP
+  selector:
+    app.kubernetes.io/name: churn-renewal-forecast
+    app.kubernetes.io/component: web
+  ports:
+    - port: 80
+      targetPort: 3000
+      name: http
diff --git a/deploy/kubernetes/base/services/web-deployment.yaml b/deploy/kubernetes/base/services/web-deployment.yaml
new file mode 100644
index 00000000..45280739
--- /dev/null
+++ b/deploy/kubernetes/base/services/web-deployment.yaml
@@ -0,0 +1,53 @@
+# deploy/kubernetes/base/services/web-deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: churn-renewal-forecast-web
+  namespace: churn-renewal-forecast
+  labels:
+    app.kubernetes.io/name: churn-renewal-forecast
+    app.kubernetes.io/component: web
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: churn-renewal-forecast
+      app.kubernetes.io/component: web
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: churn-renewal-forecast
+        app.kubernetes.io/component: web
+    spec:
+      serviceAccountName: churn-renewal-forecast
+      containers:
+        - name: web
+          image: registry.ic-ops.net/churn-renewal-forecast:latest
+          command: ["bun", "run", "start"]
+          ports:
+            - containerPort: 3000
+              name: http
+          envFrom:
+            - configMapRef:
+                name: churn-renewal-forecast-config
+            - secretRef:
+                name: churn-renewal-forecast
+          resources:
+            requests:
+              memory: 256Mi
+              cpu: 100m
+            limits:
+              memory: 512Mi
+              cpu: 500m
+          livenessProbe:
+            httpGet:
+              path: /api/health/live
+              port: 3000
+            initialDelaySeconds: 15
+            periodSeconds: 20
+          readinessProbe:
+            httpGet:
+              path: /api/health/ready
+              port: 3000
+            initialDelaySeconds: 10
+            periodSeconds: 10
diff --git a/deploy/kubernetes/base/services/worker-deployment.yaml b/deploy/kubernetes/base/services/worker-deployment.yaml
new file mode 100644
index 00000000..07d53bfa
--- /dev/null
+++ b/deploy/kubernetes/base/services/worker-deployment.yaml
@@ -0,0 +1,38 @@
+# deploy/kubernetes/base/services/worker-deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: churn-renewal-forecast-worker
+  namespace: churn-renewal-forecast
+  labels:
+    app.kubernetes.io/name: churn-renewal-forecast
+    app.kubernetes.io/component: worker
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: churn-renewal-forecast
+      app.kubernetes.io/component: worker
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: churn-renewal-forecast
+        app.kubernetes.io/component: worker
+    spec:
+      serviceAccountName: churn-renewal-forecast
+      containers:
+        - name: worker
+          image: registry.ic-ops.net/churn-renewal-forecast:latest
+          command: ["bunx", "tsx", "src/worker/index.ts"]
+          envFrom:
+            - configMapRef:
+                name: churn-renewal-forecast-config
+            - secretRef:
+                name: churn-renewal-forecast
+          resources:
+            requests:
+              memory: 256Mi
+              cpu: 100m
+            limits:
+              memory: 512Mi
+              cpu: 500m


Now let me look at the surrounding context — existing base files, any overlay structure, and probe endpoint implementation — to give a thorough review.


find /Users/steve.briggs/projects/churn-renewal-forecast/deploy -type f | sort

List all files in deploy directory


find /Users/steve.briggs/projects/churn-renewal-forecast/src/app/api/health -type f 2>/dev/null | sort; find /Users/steve.briggs/projects/churn-renewal-forecast/src -name "*health*" -type f 2>/dev/null | sort

Find health check endpoint files


(Bash completed with no output)


1	apiVersion: kustomize.config.k8s.io/v1beta1
2	kind: Kustomization
3
4	resources:
5	  - namespace.yaml
6	  - serviceaccount.yaml
7	  - secret.yaml
8
9	configMapGenerator:
10	  - name: churn-renewal-forecast-config
11	    envs:
12	      - app-config.env
13	    options:
14	      disableNameSuffixHash: true
15


1	apiVersion: kustomize.config.k8s.io/v1beta1
2	kind: Kustomization
3
4	resources:
5	  - allow-dns.yaml
6	  - allow-rds.yaml
7	  - allow-external.yaml
8


1	apiVersion: cilium.io/v2
2	kind: CiliumNetworkPolicy
3	metadata:
4	  name: churn-renewal-forecast-allow-external
5	  namespace: churn-renewal-forecast
6	  labels:
7	    app.kubernetes.io/name: churn-renewal-forecast
8	spec:
9	  endpointSelector:
10	    matchLabels:
11	      app.kubernetes.io/name: churn-renewal-forecast
12	  egress:
13	    - toFQDNs:
14	        - matchName: "clerk.com"
15	        - matchPattern: "*.clerk.com"
16	        - matchName: "api.frontapp.com"
17	        - matchName: "api.productboard.com"
18	        - matchName: "slack.com"
19	        - matchPattern: "*.slack.com"
20	      toPorts:
21	        - ports:
22	            - port: "443"
23	              protocol: TCP
24


1	apiVersion: cilium.io/v2
2	kind: CiliumNetworkPolicy
3	metadata:
4	  name: churn-renewal-forecast-allow-rds
5	  namespace: churn-renewal-forecast
6	  labels:
7	    app.kubernetes.io/name: churn-renewal-forecast
8	spec:
9	  endpointSelector:
10	    matchLabels:
11	      app.kubernetes.io/name: churn-renewal-forecast
12	  egress:
13	    - toCIDR:
14	        - 0.0.0.0/0
15	      toPorts:
16	        - ports:
17	            - port: "5432"
18	              protocol: TCP
19


1	apiVersion: cilium.io/v2
2	kind: CiliumNetworkPolicy
3	metadata:
4	  name: churn-renewal-forecast-allow-dns
5	  namespace: churn-renewal-forecast
6	  labels:
7	    app.kubernetes.io/name: churn-renewal-forecast
8	spec:
9	  endpointSelector:
10	    matchLabels:
11	      app.kubernetes.io/name: churn-renewal-forecast
12	  egress:
13	    - toEndpoints:
14	        - matchLabels:
15	            k8s:io.kubernetes.pod.namespace: kube-system
16	            k8s-app: kube-dns
17	      toPorts:
18	        - ports:
19	            - port: "53"
20	              protocol: UDP
21	          rules:
22	            dns:
23	              - matchPattern: "*"
24


1	apiVersion: v1
2	kind: ServiceAccount
3	metadata:
4	  name: churn-renewal-forecast
5	  namespace: churn-renewal-forecast
6	  labels:
7	    app.kubernetes.io/name: churn-renewal-forecast
8


1	apiVersion: batch/v1
2	kind: Job
3	metadata:
4	  name: churn-renewal-forecast-migrate
5	  namespace: churn-renewal-forecast
6	  labels:
7	    app.kubernetes.io/name: churn-renewal-forecast
8	    app.kubernetes.io/component: migrate
9	spec:
10	  ttlSecondsAfterFinished: 300
11	  backoffLimit: 3
12	  template:
13	    metadata:
14	      labels:
15	        app.kubernetes.io/name: churn-renewal-forecast
16	        app.kubernetes.io/component: migrate
17	    spec:
18	      serviceAccountName: churn-renewal-forecast
19	      restartPolicy: OnFailure
20	      containers:
21	        - name: migrate
22	          image: registry.ic-ops.net/churn-renewal-forecast:latest
23	          command: ["bunx", "prisma", "migrate", "deploy"]
24	          envFrom:
25	            - configMapRef:
26	                name: churn-renewal-forecast-config
27	            - secretRef:
28	                name: churn-renewal-forecast
29


1	apiVersion: external-secrets.io/v1beta1
2	kind: ExternalSecret
3	metadata:
4	  name: churn-renewal-forecast
5	  namespace: churn-renewal-forecast
6	  labels:
7	    app.kubernetes.io/name: churn-renewal-forecast
8	spec:
9	  refreshInterval: 1h
10	  secretStoreRef:
11	    name: vault
12	    kind: ClusterSecretStore
13	  target:
14	    name: churn-renewal-forecast
15	    creationPolicy: Owner
16	  data:
17	    - secretKey: DATABASE_URL
18	      remoteRef:
19	        key: /internal/churn-renewal-forecast
20	        property: DATABASE_URL
21	    - secretKey: CLERK_SECRET_KEY
22	      remoteRef:
23	        key: /internal/churn-renewal-forecast
24	        property: CLERK_SECRET_KEY
25	    - secretKey: FRONT_API_TOKEN
26	      remoteRef:
27	        key: /internal/churn-renewal-forecast
28	        property: FRONT_API_TOKEN
29	    - secretKey: FRONT_API_TOKEN_2
30	      remoteRef:
31	        key: /internal/churn-renewal-forecast
32	        property: FRONT_API_TOKEN_2
33	    - secretKey: PRODUCTBOARD_API_KEY
34	      remoteRef:
35	        key: /internal/churn-renewal-forecast
36	        property: PRODUCTBOARD_API_KEY
37	    - secretKey: SLACK_BOT_TOKEN
38	      remoteRef:
39	        key: /internal/churn-renewal-forecast
40	        property: SLACK_BOT_TOKEN
41


find /Users/steve.briggs/projects/churn-renewal-forecast/src/app/api -type f | sort

List API route files


1	APP_URL=https://churn-forecast.infrastructure.immuta.io
2	NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
3	NEXT_PUBLIC_PRODUCTBOARD_WORKSPACE=
4	SLACK_TEST_CHANNEL=
5	PG_POOL_MAX=10
6	PG_POOL_IDLE_TIMEOUT_MS=30000
7	PG_POOL_CONNECTION_TIMEOUT_MS=2000
8	NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
9	NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
10	NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
11	NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
12


No `/api/health` routes exist in the codebase. Let me check for any overlay structure and the worker entrypoint to complete the review.


find /Users/steve.briggs/projects/churn-renewal-forecast/deploy -type d | sort

List all directories under deploy


find /Users/steve.briggs/projects/churn-renewal-forecast/src/worker -type f | sort

List worker source files


1	import { getBoss, closeBoss } from "@/lib/jobs/boss";
2	import { logger } from "@/lib/logger";
3	import { getApiTokenCount } from "@/lib/front";
4	import { handleSyncFrontAccounts } from "./handlers/sync-front-accounts";
5	import { handleSyncFrontContacts } from "./handlers/sync-front-contacts";
6	import { handleSyncFrontAccountContacts } from "./handlers/sync-front-account-contacts";
7	import { handleSyncFrontConversations } from "./handlers/sync-front-conversations";
8	import { handleSyncFrontAccountConversations } from "./handlers/sync-front-account-conversations";
9	import { handleSyncProductboardCompanies } from "./handlers/sync-productboard-companies";
10	import { handleSyncProductboardNotes } from "./handlers/sync-productboard-notes";
11	import { handleSyncProductboardFeatures } from "./handlers/sync-productboard-features";
12	import { handleSyncFrontCustomerConversations } from "./handlers/sync-front-customer-conversations";
13	import { handleSyncProductboardCustomerNotes } from "./handlers/sync-productboard-customer-notes";
14	import { handleImportConsumption } from "./handlers/import-consumption";
15	import { handleDetectUsageDrops } from "./handlers/detect-usage-drops";
16	import { cleanupStaleJobs } from "@/lib/jobs/cleanup";
17
18	async function startWorker() {
19	  try {
20	    logger.log("Starting pg-boss worker...");
21	    const boss = await getBoss();
22
23	    // Clean up any stale jobs from previous runs
24	    logger.log("Cleaning up stale jobs...");
25	    await cleanupStaleJobs();
26
27	    // Create queues before registering workers
28	    const queues = [
29	      "sync-front-accounts",
30	      "sync-front-contacts",
31	      "sync-front-account-contacts",
32	      "sync-front-conversations",
33	      "sync-front-account-conversations",
34	      "sync-front-customer-conversations",
35	      "sync-productboard-companies",
36	      "sync-productboard-notes",
37	      "sync-productboard-customer-notes",
38	      "sync-productboard-features",
39	      "import-consumption",
40	      "detect-usage-drops",
41	    ];
42
43	    logger.log("Creating job queues...");
44	    for (const queue of queues) {
45	      await boss.createQueue(queue);
46	    }
47
48	    // Register job handlers
49	    await boss.work("sync-front-accounts", handleSyncFrontAccounts);
50	    await boss.work("sync-front-contacts", handleSyncFrontContacts);
51	    await boss.work("sync-front-conversations", handleSyncFrontConversations);
52
53	    // Register sub-job handlers with concurrency options
54	    const frontConcurrency = 3 * Math.max(1, getApiTokenCount());
55	    logger.log(`[Worker] Front sub-job concurrency: ${frontConcurrency} (${Math.max(1, getApiTokenCount())} API key(s))`);
56
57	    await boss.work(
58	      "sync-front-account-contacts",
59	      {
60	        localConcurrency: frontConcurrency,
61	      },
62	      handleSyncFrontAccountContacts
63	    );
64
65	    await boss.work(
66	      "sync-front-account-conversations",
67	      {
68	        localConcurrency: frontConcurrency,
69	      },
70	      handleSyncFrontAccountConversations
71	    );
72
73	    await boss.work("sync-productboard-companies", handleSyncProductboardCompanies);
74	    await boss.work("sync-productboard-notes", handleSyncProductboardNotes);
75	    await boss.work("sync-productboard-features", handleSyncProductboardFeatures);
76	    await boss.work("sync-front-customer-conversations", handleSyncFrontCustomerConversations);
77	    await boss.work("sync-productboard-customer-notes", handleSyncProductboardCustomerNotes);
78	    await boss.work("import-consumption", handleImportConsumption);
79	    await boss.work("detect-usage-drops", handleDetectUsageDrops);
80
81	    logger.log("✓ Worker started successfully, listening for jobs...");
82
83	    // Handle graceful shutdown
84	    process.on("SIGINT", async () => {
85	      logger.log("\nShutting down worker...");
86	      await closeBoss();
87	      process.exit(0);
88	    });
89
90	    process.on("SIGTERM", async () => {
91	      logger.log("\nShutting down worker...");
92	      await closeBoss();
93	      process.exit(0);
94	    });
95	  } catch (error) {
96	    console.error("Failed to start worker:", error);
97	    process.exit(1);
98	  }
99	}
100
101	startWorker();
102


I have everything I need. Here is the full review, Sir Charles of St Cheese.

---

### Strengths

- **Label consistency is bussin'.** All five resources use `app.kubernetes.io/name` + `app.kubernetes.io/component` throughout — metadata, pod template, and Service selector all align perfectly. No label drift.
- **Ingress TLS setup ate.** `ingressClassName: nginx`, distinct `tls` block, `secretName` that matches the ESO-managed secret naming convention, and the backend referencing the Service port by `name: http` (rather than a raw number) — all best-practice choices.
- **Service correctly isolates web only.** The selector targeting `component: web` means the worker pods are never accidentally routed traffic. W.
- **Resources are sane for a Next.js app.** 256Mi/100m requests and 512Mi/500m limits are reasonable starting values for a single-tenant internal tool. The 2:1 memory ratio gives headroom without over-committing.
- **Worker graceful shutdown is handled in code.** `SIGTERM` and `SIGINT` handlers in `src/worker/index.ts` call `closeBoss()` — the Deployment just needs to deliver SIGTERM (which Kubernetes does by default), so no extra `lifecycle` hook is needed there. Low-key clean.
- **Init job pattern for migrations** is properly separated from the Deployments — existing `init/job.yaml` already handles this and the new Deployments don't try to run migrations themselves. Clock that.

---

### Issues

#### Critical (Must Fix)

**1. Health probe endpoints do not exist.**

Both liveness (`/api/health/live`) and readiness (`/api/health/ready`) are configured in `web-deployment.yaml`, but there are zero files under `src/app/api/health/` in this repo. The web pod will start, the probes will immediately begin returning 404, kubelet will mark the pod `NotReady`, and after `failureThreshold` (default 3) consecutive liveness failures it will be killed and restarted in a loop. The Deployment will never stabilize — this is a guaranteed CrashLoopBackOff / permanent `Unready` on first deploy.

You must either:
- Create `src/app/api/health/live/route.ts` and `src/app/api/health/ready/route.ts` before this Deployment ships, **or**
- Temporarily point both probes at an existing authenticated-free endpoint (risky — if Clerk middleware intercepts it you still get 401s), **or**
- Use `tcpSocket` probes on port 3000 as a stopgap.

The health routes should be exempted from Clerk auth middleware as well. Check `src/middleware.ts` — if it protects all `/api/*` routes, a 401 response is treated the same as a 404 by the probe and the pod will still fail.

**2. No `protocol` on the Service port.**

```yaml
ports:
  - port: 80
    targetPort: 3000
    name: http
```

`protocol` defaults to `TCP` when omitted — functionally correct here — but several admission controllers and linters (including `kube-score` and some versions of the nginx-ingress webhook) flag this as invalid and may reject the resource. Add `protocol: TCP` explicitly.

---

#### Important (Should Fix)

**3. Worker Deployment has no liveness probe — silent hangs go undetected.**

The worker is a long-running pg-boss consumer. If the DB connection drops or pg-boss enters a broken state (which it can on connection timeout), `startWorker()` won't throw — it'll just sit silently, processing nothing. No probe means kubelet never restarts it. You could use a simple file-based liveness check: have the worker touch a file every N seconds in a keep-alive loop, and have the probe check `exec: cat /tmp/worker-alive` with a `failureThreshold` that gives enough slack for normal processing gaps. This is standard practice for queue consumers.

**4. No `failureThreshold` / `successThreshold` tuning on probes.**

Kubernetes defaults: `failureThreshold: 3`, `successThreshold: 1`, `timeoutSeconds: 1`. For a Next.js app with a cold start under load, 1 second timeout is sus. A slow DB connection at startup (especially with the Prisma connection pool warming up) can make `/api/health/ready` take 2–3 seconds the first time. The liveness probe hitting at `initialDelay + 1×period = 15s` into startup and only having a 1-second timeout risks a spurious restart. Recommend at minimum:

```yaml
livenessProbe:
  timeoutSeconds: 5
  failureThreshold: 3
readinessProbe:
  timeoutSeconds: 5
  failureThreshold: 3
```

**5. `image: ...latest` is a reliability and reproducibility risk.**

Both Deployments pin to `:latest`. This means:
- `imagePullPolicy` defaults to `IfNotPresent` when the tag is not `latest`, but for `latest` it defaults to `Always` — so every pod restart triggers a registry pull.
- If the registry is unavailable at restart time, the pod gets `ImagePullBackOff`.
- There is no way to roll back to a known-good image without retagging.

This is appropriate for a base layer where overlays will substitute a specific digest or SHA-tagged image. If there are no overlays planned (the `deploy/kubernetes/` directory has no `overlays/` tree today), then `:latest` should be treated as a temporary placeholder with a comment making that contract explicit. If overlays are coming in a future task, that's fine — just flag it.

**6. Missing `app.kubernetes.io/part-of` and `app.kubernetes.io/version` labels on Ingress.**

Minor labeling gap: the Ingress `metadata.labels` only has `app.kubernetes.io/name`. The Deployments and Service include `app.kubernetes.io/component`. For consistency and to make `kubectl get all -l app.kubernetes.io/name=churn-renewal-forecast` actually return the Ingress alongside everything else, add `app.kubernetes.io/component: web` to the Ingress labels.

---

#### Minor (Nice to Have)

**7. No `minReadySeconds` on the web Deployment.**

With `replicas: 1`, there is zero overlap during a rolling update — the old pod is terminated, the new pod starts, and users see downtime until the new pod passes its readiness probe. Setting `minReadySeconds: 10` doesn't fix the `replicas: 1` gap (that requires bumping to 2), but it does prevent a pod that passes probes and then immediately crashes from being considered "available." Low-key worth adding.

**8. No `terminationGracePeriodSeconds` override.**

Kubernetes default is 30 seconds. The worker's SIGTERM handler calls `closeBoss()` which drains in-flight jobs. If a job is mid-execution (e.g., a large Front sync), 30 seconds may not be enough. Consider setting `terminationGracePeriodSeconds: 60` on the worker to match the typical job timeout expectation.

**9. `targetPort` should reference port by name, not number.**

In `service.yaml`:
```yaml
targetPort: 3000
```
Since the web Deployment already names the container port `http`, using `targetPort: http` is more robust — if the port number ever changes, only the Deployment needs updating.

**10. No `app.kubernetes.io/component` on the Ingress metadata labels.**

As noted above — minor consistency gap. All other resources set this label.

---

### Assessment

**Ready to merge?** No

**Reasoning:** Issue #1 is a guaranteed deploy failure. The health probe paths referenced in `web-deployment.yaml` (`/api/health/live`, `/api/health/ready`) do not exist anywhere in the application code, and Clerk middleware would likely intercept them anyway even if they did. This will produce a `CrashLoopBackOff` or permanently `NotReady` web pod the moment this lands in the cluster — it hits different, and not in a good way. The `protocol: TCP` omission (#2) is a smaller but real risk depending on which admission webhooks run in the target cluster.

The remaining issues are real improvements but not deploy-blockers. Fix the probe endpoints (and middleware exemption) first, then ship. The rest can follow as a polish PR.