From f67e844163004df3cee0aa953b9f1bb448972f84 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:24:53 +0530
Subject: [PATCH 01/12] ci: add scheduled public API e2e tests

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh    | 489 +++++++++++++++++++++
 .github/workflows/api-e2e-tests.yml        |  63 +++
 docs/adr/0012-api-e2e-test-architecture.md | 140 ++++++
 docs/adr/0013-api-e2e-test-suite-design.md |  74 ++++
 docs/adr/README.md                         |   2 +
 scripts/builders/backend.env               |   2 +-
 6 files changed, 769 insertions(+), 1 deletion(-)
 create mode 100755 .github/scripts/public-api-e2e-tests.sh
 create mode 100644 .github/workflows/api-e2e-tests.yml
 create mode 100644 docs/adr/0012-api-e2e-test-architecture.md
 create mode 100644 docs/adr/0013-api-e2e-test-suite-design.md

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
new file mode 100755
index 0000000000..53255b7398
--- /dev/null
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -0,0 +1,489 @@
+#!/usr/bin/env bash
+# End-to-end tests for the Public API (scheduled regression suite).
+#
+# The suite seeds its own test data and communicates with the API over HTTP only.
+# Database setup/reset is handled externally.
+#
+# Required environment variables (same names as GitHub Actions secrets/vars):
+#   AUTH0_API_E2E_CLIENT_ID
+#   AUTH0_API_E2E_CLIENT_SECRET
+#   AUTH0_STAGING_AUDIENCE
+#   AUTH0_STAGING_ISSUER
+#   CDP_API_E2E_BASE_URL
+#
+# Do not enable `set -x`; requests include sensitive credentials.
+#
+# Structure:
+#   - Shared helpers (api, check, require)
+#   - Seed data
+#   - One test suite per resource
+#
+# Each test case is an api call followed by check. Stateful suites are a short
+# workflow. Register new suites in main.
+
+set -euo pipefail
+
+if [[ $- == *x* ]]; then
+  echo "error: refusing to run with xtrace (set -x); credentials would leak" >&2
+  exit 1
+fi
+
+for cmd in curl jq; do
+  command -v "$cmd" >/dev/null || {
+    echo "error: '$cmd' is required" >&2
+    exit 1
+  }
+done
+
+: "${AUTH0_API_E2E_CLIENT_ID:?set AUTH0_API_E2E_CLIENT_ID}"
+: "${AUTH0_API_E2E_CLIENT_SECRET:?set AUTH0_API_E2E_CLIENT_SECRET}"
+: "${AUTH0_STAGING_AUDIENCE:?set AUTH0_STAGING_AUDIENCE}"
+: "${AUTH0_STAGING_ISSUER:?set AUTH0_STAGING_ISSUER}"
+: "${CDP_API_E2E_BASE_URL:?set CDP_API_E2E_BASE_URL}"
+
+VERIFIED_BY="${VERIFIED_BY:-jordan.lee+e2e@example.com}"
+RUN_ID="${GITHUB_RUN_ID:-$(date +%s)}-${RANDOM}"
+SOURCE="lfxOne-api-e2e"
+
+PASS=0
+FAIL=0
+TOKEN=""
+HTTP_CODE=""
+BODY=""
+
+die() {
+  echo "error: $*" >&2
+  exit 1
+}
+
+api() {
+  local method=$1 path=$2 body=${3-}
+  local -a args=(-sS -w '\n%{http_code}' -X "$method" "${CDP_API_E2E_BASE_URL}${path}"
+    -H 'Content-Type: application/json'
+    -H "Authorization: Bearer ${TOKEN}")
+  [[ -n $body ]] && args+=(-d "$body")
+
+  local response
+  response="$(curl "${args[@]}")"
+  HTTP_CODE="$(printf '%s' "$response" | tail -n1)"
+  BODY="$(printf '%s' "$response" | sed '$d')"
+}
+
+check() {
+  local name=$1 expected=$2
+  shift 2
+  if [[ $HTTP_CODE != "$expected" ]]; then
+    printf '  FAIL  %s — expected HTTP %s, got %s\n' "$name" "$expected" "$HTTP_CODE"
+    jq . <<<"$BODY" 2>/dev/null || printf '%s\n' "$BODY"
+    FAIL=$((FAIL + 1))
+    return
+  fi
+  local expr
+  for expr in "$@"; do
+    if ! jq -e "$expr" >/dev/null 2>&1 <<<"$BODY"; then
+      printf '  FAIL  %s (%s)\n' "$name" "$expr"
+      jq . <<<"$BODY" 2>/dev/null || printf '%s\n' "$BODY"
+      FAIL=$((FAIL + 1))
+      return
+    fi
+  done
+  printf '  PASS  %s (HTTP %s)\n' "$name" "$HTTP_CODE"
+  PASS=$((PASS + 1))
+}
+
+require() {
+  local expected=$1 name=$2
+  [[ $HTTP_CODE == "$expected" ]] || die "$name — expected HTTP $expected, got $HTTP_CODE"
+}
+
+json() {
+  jq -n "$@"
+}
+
+fetch_token() {
+  local response
+  response="$(curl -sS -X POST "${AUTH0_STAGING_ISSUER}/oauth/token" \
+    -H 'Content-Type: application/json' \
+    -d "$(json \
+      --arg client_id "$AUTH0_API_E2E_CLIENT_ID" \
+      --arg client_secret "$AUTH0_API_E2E_CLIENT_SECRET" \
+      --arg audience "$AUTH0_STAGING_AUDIENCE" \
+      '{grant_type:"client_credentials",client_id:$client_id,client_secret:$client_secret,audience:$audience}')")"
+
+  TOKEN="$(jq -r '.access_token // empty' <<<"$response")"
+  [[ -n $TOKEN ]] || die "Auth0 token failed: $(jq -c '{error,error_description}' <<<"$response" 2>/dev/null || echo unavailable)"
+}
+
+# ── fixtures ─────────────────────────────────────────────────────────────────
+
+ACME_NAME="Acme-${RUN_ID}"
+ACME_DOMAIN="acme-${RUN_ID}.example.com"
+ACME_ID=""
+
+GLOBEX_NAME="Globex-${RUN_ID}"
+GLOBEX_DOMAIN="globex-${RUN_ID}.example.com"
+GLOBEX_ID=""
+
+PERSON_NAME="Jordan Lee"
+PERSON_LFID="jordan.lee-${RUN_ID}"
+PERSON_ID=""
+
+seed() {
+  echo "=== seed (${RUN_ID}) ==="
+
+  api POST /organizations "$(json \
+    --arg name "$ACME_NAME" --arg domain "$ACME_DOMAIN" --arg source "$SOURCE" \
+    '{name:$name, domain:$domain, source:$source}')"
+  require 201 "create Acme"
+  ACME_ID="$(jq -r '.id' <<<"$BODY")"
+
+  api POST /organizations "$(json \
+    --arg name "$GLOBEX_NAME" --arg domain "$GLOBEX_DOMAIN" --arg source "$SOURCE" \
+    '{name:$name, domain:$domain, source:$source}')"
+  require 201 "create Globex"
+  GLOBEX_ID="$(jq -r '.id' <<<"$BODY")"
+
+  api POST /members "$(json \
+    --arg lfid "$PERSON_LFID" --arg by "$VERIFIED_BY" --arg name "$PERSON_NAME" '{
+      displayName: $name,
+      identities: [{
+        value: $lfid,
+        platform: "lfid",
+        type: "username",
+        source: "lfxOne",
+        verified: true,
+        verifiedBy: $by
+      }]
+    }')"
+  require 201 "create Jordan"
+  PERSON_ID="$(jq -r '.memberId' <<<"$BODY")"
+  [[ -n $PERSON_ID && $PERSON_ID != null ]] || die "create Jordan returned no memberId"
+
+  echo "  acme=$ACME_ID"
+  echo "  globex=$GLOBEX_ID"
+  echo "  person=$PERSON_ID"
+}
+
+# ── /organizations ───────────────────────────────────────────────────────────
+
+suite_organizations() {
+  echo
+  echo "=== /organizations ==="
+
+  local name_q
+  name_q="$(jq -nr --arg n "$ACME_NAME" '$n|@uri')"
+
+  api GET "/organizations?domain=${ACME_DOMAIN}"
+  check "GET find Acme by domain" 200 \
+    ".id == \"$ACME_ID\"" \
+    ".name == \"$ACME_NAME\""
+
+  api GET "/organizations?name=${name_q}"
+  check "GET find Acme by name" 200 ".id == \"$ACME_ID\""
+
+  api GET "/organizations?name=${name_q}&domain=${ACME_DOMAIN}"
+  check "GET find Acme by name+domain" 200 ".id == \"$ACME_ID\""
+
+  api GET "/organizations?name=${name_q}&domain=example.com"
+  check "GET name/domain mismatch" 404
+
+  api GET "/organizations"
+  check "GET missing params" 400
+
+  api GET "/organizations?domain=missing-${RUN_ID}.example.com"
+  check "GET unknown domain" 404
+
+  api GET "/organizations?domain=not-a-valid-domain"
+  check "GET invalid domain" 400
+
+  api POST /organizations "$(json \
+    --arg name "$ACME_NAME" --arg domain "$ACME_DOMAIN" --arg source "$SOURCE" \
+    '{name:$name, domain:$domain, source:$source}')"
+  check "POST recreate Acme is idempotent" 201 ".id == \"$ACME_ID\""
+
+  api POST /organizations "$(json \
+    --arg name "Bad-${RUN_ID}" --arg source "$SOURCE" \
+    '{name:$name, domain:"not-a-valid-domain", source:$source}')"
+  check "POST reject invalid domain" 400
+}
+
+# ── /members ─────────────────────────────────────────────────────────────────
+
+suite_members() {
+  echo
+  echo "=== /members ==="
+
+  local sam_lfid="sam.rivera-${RUN_ID}"
+  local sam_id
+
+  api POST /members "$(json \
+    --arg lfid "$sam_lfid" --arg by "$VERIFIED_BY" '{
+      displayName: "Sam Rivera",
+      identities: [{
+        value: $lfid,
+        platform: "lfid",
+        type: "username",
+        source: "lfxOne",
+        verified: true,
+        verifiedBy: $by
+      }]
+    }')"
+  check "POST create Sam" 201 'has("memberId")'
+  sam_id="$(jq -r '.memberId' <<<"$BODY")"
+
+  api POST /members "$(json '{displayName:"No Identities", identities:[]}')"
+  check "POST reject empty identities" 400
+
+  api POST /members/resolve "$(json --arg lfid "$PERSON_LFID" '{lfids:[$lfid]}')"
+  check "POST resolve Jordan by lfid" 200 ".memberId == \"$PERSON_ID\""
+
+  api POST /members/resolve "$(json --arg lfid "$sam_lfid" '{lfids:[$lfid]}')"
+  check "POST resolve Sam by lfid" 200 ".memberId == \"$sam_id\""
+
+  api POST /members/resolve "$(json --arg lfid "nobody-${RUN_ID}" '{lfids:[$lfid]}')"
+  check "POST resolve unknown lfid" 404
+
+  api POST /members/resolve "$(json '{lfids:[]}')"
+  check "POST resolve reject empty lfids" 400
+}
+
+# ── /members/:id/identities ──────────────────────────────────────────────────
+
+suite_member_identities() {
+  echo
+  echo "=== /members/:id/identities ==="
+
+  local email="jordan.lee+${RUN_ID}@example.com"
+  local identity_id
+
+  api GET "/members/${PERSON_ID}/identities"
+  check "GET lists seeded lfid" 200 \
+    '.identities | type == "array"' \
+    ".identities | map(select(.platform == \"lfid\" and .value == \"$PERSON_LFID\")) | length == 1"
+
+  api POST "/members/${PERSON_ID}/identities" "$(json \
+    --arg email "$email" --arg by "$VERIFIED_BY" '{
+      value: $email,
+      platform: "email",
+      type: "email",
+      source: "lfxOne",
+      verified: false,
+      verifiedBy: $by
+    }')"
+  check "POST add email identity" 201 \
+    ".value == \"$email\"" \
+    '.verified == false'
+  identity_id="$(jq -r '.id' <<<"$BODY")"
+
+  api POST "/members/${PERSON_ID}/identities" "$(json \
+    --arg email "$email" '{
+      value: $email,
+      platform: "email",
+      type: "email",
+      source: "lfxOne",
+      verified: false
+    }')"
+  check "POST same identity is idempotent" 200 ".id == \"$identity_id\""
+
+  api GET "/members/${PERSON_ID}/identities"
+  check "GET includes email" 200 \
+    ".identities | map(select(.id == \"$identity_id\")) | length == 1"
+
+  api PATCH "/members/${PERSON_ID}/identities/${identity_id}" \
+    "$(json --arg by "$VERIFIED_BY" '{verified:true, verifiedBy:$by}')"
+  check "PATCH verify email" 200 '.verified == true'
+
+  api PATCH "/members/${PERSON_ID}/identities/${identity_id}" \
+    "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')"
+  check "PATCH reject email deletes unused identity" 204
+
+  api GET "/members/${PERSON_ID}/identities"
+  check "GET email gone after reject" 200 \
+    ".identities | map(select(.id == \"$identity_id\")) | length == 0"
+}
+
+# ── /members/:id/work-experiences ────────────────────────────────────────────
+
+suite_member_work_experiences() {
+  echo
+  echo "=== /members/:id/work-experiences ==="
+
+  local acme_we globex_we doomed_we
+
+  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+    --arg org "$ACME_ID" --arg by "$VERIFIED_BY" '{
+      organizationId: $org,
+      jobTitle: "Platform Engineer",
+      verified: false,
+      verifiedBy: $by,
+      source: "lfxOne",
+      startDate: "2024-01-01T00:00:00.000Z",
+      endDate: "2024-12-31T00:00:00.000Z"
+    }')"
+  check "POST create Acme stint" 201 \
+    'has("id") and (has("workExperiences") | not)' \
+    ".organizationName == \"$ACME_NAME\"" \
+    ".organizationDomains | index(\"$ACME_DOMAIN\") != null"
+  acme_we="$(jq -r '.id' <<<"$BODY")"
+
+  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+    --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
+      organizationId: $org,
+      jobTitle: "Software Engineer",
+      verified: false,
+      verifiedBy: $by,
+      source: "lfxOne",
+      startDate: "2020-01-01T00:00:00.000Z",
+      endDate: "2022-06-01T00:00:00.000Z"
+    }')"
+  check "POST create Globex stint" 201
+  globex_we="$(jq -r '.id' <<<"$BODY")"
+
+  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+    --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
+      organizationId: $org,
+      jobTitle: "ignored title",
+      verified: false,
+      verifiedBy: $by,
+      source: "email-domain",
+      startDate: "2021-01-01T00:00:00.000Z",
+      endDate: "2023-01-01T00:00:00.000Z"
+    }')"
+  check "POST overlapping Globex email-domain row" 201
+
+  api GET "/members/${PERSON_ID}/work-experiences"
+  check "GET groups to two visible rows" 200 \
+    '.workExperiences | length == 2' \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .jobTitle == \"Software Engineer\"" \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .source | test(\"lfxOne\")" \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .source | test(\"email-domain\")" \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .startDate | startswith(\"2020-01-01\")" \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .endDate | startswith(\"2023-01-01\")" \
+    ".workExperiences[] | select(.organizationId == \"$GLOBEX_ID\") | .id == \"$globex_we\""
+
+  api PUT "/members/${PERSON_ID}/work-experiences/${globex_we}" "$(json \
+    --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
+      organizationId: $org,
+      jobTitle: "Senior Software Engineer",
+      verified: false,
+      verifiedBy: $by,
+      source: "lfxOne",
+      startDate: "2020-01-01T00:00:00.000Z",
+      endDate: "2022-06-01T00:00:00.000Z"
+    }')"
+  check "PUT update Globex title" 200 '.jobTitle == "Senior Software Engineer"'
+
+  api GET "/members/${PERSON_ID}/work-experiences"
+  check "GET shows updated title" 200 \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .jobTitle == \"Senior Software Engineer\""
+
+  api PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \
+    "$(json --arg by "$VERIFIED_BY" '{verified:true, verifiedBy:$by}')"
+  check "PATCH verify Globex" 200 \
+    '.verified == true' \
+    ".id == \"$globex_we\""
+
+  api GET "/members/${PERSON_ID}/work-experiences"
+  check "GET shows Globex verified" 200 \
+    ".workExperiences[] | select(.id == \"$globex_we\") | .verified == true"
+
+  api PATCH "/members/${PERSON_ID}/work-experiences/${acme_we}" \
+    "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')"
+  check "PATCH reject Acme" 200 '.verified == false'
+
+  api GET "/members/${PERSON_ID}/work-experiences"
+  check "GET reject hides Acme" 200 \
+    '.workExperiences | length == 1' \
+    ".workExperiences[0].id == \"$globex_we\""
+
+  api PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \
+    "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')"
+  check "PATCH reject Globex" 200
+
+  api GET "/members/${PERSON_ID}/work-experiences"
+  check "GET all rejects leave empty list" 200 '.workExperiences | length == 0'
+
+  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+    --arg org "$ACME_ID" --arg by "$VERIFIED_BY" '{
+      organizationId: $org,
+      jobTitle: "Contractor",
+      verified: false,
+      verifiedBy: $by,
+      source: "lfxOne",
+      startDate: "2025-01-01T00:00:00.000Z",
+      endDate: "2025-06-01T00:00:00.000Z"
+    }')"
+  check "POST create stint for delete" 201
+  doomed_we="$(jq -r '.id' <<<"$BODY")"
+
+  api DELETE "/members/${PERSON_ID}/work-experiences/${doomed_we}"
+  check "DELETE work experience" 204
+
+  api GET "/members/${PERSON_ID}/work-experiences"
+  check "GET empty after delete" 200 '.workExperiences | length == 0'
+}
+
+# ── /members/:id/maintainer-roles ────────────────────────────────────────────
+
+suite_member_maintainer_roles() {
+  echo
+  echo "=== /members/:id/maintainer-roles ==="
+
+  api GET "/members/${PERSON_ID}/maintainer-roles"
+  check "GET empty roles for fresh member" 200 \
+    '.maintainerRoles | type == "array"' \
+    '.maintainerRoles | length == 0'
+}
+
+# ── /members/:id/project-affiliations ────────────────────────────────────────
+# PATCH needs a project the member already belongs to (activity-backed). Without
+# that fixture we only cover GET + unknown-project 404.
+
+suite_member_project_affiliations() {
+  echo
+  echo "=== /members/:id/project-affiliations ==="
+
+  local missing_project="00000000-0000-4000-8000-000000000099"
+
+  api GET "/members/${PERSON_ID}/project-affiliations"
+  check "GET empty affiliations for fresh member" 200 \
+    '.projectAffiliations | type == "array"' \
+    '.projectAffiliations | length == 0'
+
+  api PATCH "/members/${PERSON_ID}/project-affiliations/${missing_project}" \
+    "$(json --arg by "$VERIFIED_BY" --arg org "$ACME_ID" '{
+      verifiedBy: $by,
+      affiliations: [{
+        organizationId: $org,
+        dateStart: "2024-01-01T00:00:00.000Z",
+        dateEnd: null
+      }]
+    }')"
+  check "PATCH unknown project" 404
+}
+
+main() {
+  echo "Public API e2e tests"
+  echo "  run:  $RUN_ID"
+  echo "  base: $CDP_API_E2E_BASE_URL"
+  echo
+
+  fetch_token
+  echo "Authenticated."
+
+  seed
+  suite_organizations
+  suite_members
+  suite_member_identities
+  suite_member_work_experiences
+  suite_member_maintainer_roles
+  suite_member_project_affiliations
+
+  echo
+  echo "=== Results ==="
+  printf 'Passed: %s\n' "$PASS"
+  printf 'Failed: %s\n' "$FAIL"
+  [[ $FAIL -eq 0 ]]
+}
+
+main "$@"
diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
new file mode 100644
index 0000000000..afc3b7dd5d
--- /dev/null
+++ b/.github/workflows/api-e2e-tests.yml
@@ -0,0 +1,63 @@
+name: API e2e tests
+
+on:
+  schedule:
+    - cron: '0 1 * * *'
+  workflow_dispatch:
+
+concurrency:
+  group: api-e2e-tests
+  cancel-in-progress: false
+
+env:
+  CLOUD_ENV: lf-oracle-staging
+  ORACLE_DOCKER_USERNAME: ${{ secrets.ORACLE_DOCKER_USERNAME }}
+  ORACLE_DOCKER_PASSWORD: ${{ secrets.ORACLE_DOCKER_PASSWORD }}
+  ORACLE_USER: ${{ secrets.ORACLE_USER }}
+  ORACLE_TENANT: ${{ secrets.ORACLE_TENANT }}
+  ORACLE_REGION: ${{ secrets.ORACLE_REGION }}
+  ORACLE_FINGERPRINT: ${{ secrets.ORACLE_FINGERPRINT }}
+  ORACLE_KEY: ${{ secrets.ORACLE_KEY }}
+  ORACLE_CLUSTER: ${{ secrets.ORACLE_STAGING_CLUSTER }}
+  AUTH0_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_API_E2E_CLIENT_ID }}
+  AUTH0_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_API_E2E_CLIENT_SECRET }}
+  AUTH0_STAGING_AUDIENCE: ${{ vars.AUTH0_STAGING_AUDIENCE }}
+  AUTH0_STAGING_ISSUER: ${{ vars.AUTH0_STAGING_ISSUER }}
+  CDP_API_E2E_BASE_URL: ${{ vars.CDP_API_E2E_BASE_URL }}
+
+jobs:
+  e2e-tests:
+    runs-on: ubuntu-latest
+    timeout-minutes: 90
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: main
+
+      - name: Install OCI
+        run: |
+          curl -L https://raw.githubusercontent.com/oracle/oci-cli/master/scripts/install/install.sh > install.sh
+          chmod +x install.sh
+          ./install.sh --accept-all-defaults
+          echo "OCI_CLI_DIR=/home/runner/bin" >> $GITHUB_ENV
+
+      - name: Update PATH
+        run: echo "${{ env.OCI_CLI_DIR }}" >> $GITHUB_PATH
+
+      - uses: ./.github/actions/node/builder
+        with:
+          services: api-e2e
+
+      - name: Wait for api-e2e
+        run: |
+          set -euo pipefail
+          health_url="${CDP_API_E2E_BASE_URL%/v1}/health"
+          for i in $(seq 1 36); do
+            curl -fsS --max-time 10 "$health_url" >/dev/null && exit 0
+            sleep 10
+          done
+          echo "api-e2e not healthy: $health_url" >&2
+          exit 1
+
+      - name: Run Public API e2e tests
+        run: bash .github/scripts/public-api-e2e-tests.sh
diff --git a/docs/adr/0012-api-e2e-test-architecture.md b/docs/adr/0012-api-e2e-test-architecture.md
new file mode 100644
index 0000000000..99ef1010a1
--- /dev/null
+++ b/docs/adr/0012-api-e2e-test-architecture.md
@@ -0,0 +1,140 @@
+# ADR-0012: API e2e test architecture
+
+**Date**: 2026-07-25
+**Status**: accepted
+**Deciders**: Yeganathan S
+
+## Context
+
+We need to catch regressions on the API on `main` sooner (auth, routing, validation, and the sync HTTP response contract) without waiting for a consumer to hit a break. Relying on manually running the script every time is hard and not reliable. It pollutes staging data, depends on ambient fixtures, and does not work as a team habit.
+
+A fuller API e2e harness in the server test setup (PR CI, isolated fixtures, typed asserts) is planned and sits in the backlog. Until that is ready, we need a short-term automated check that is isolated from staging data and cheap to operate.
+
+**Focus for now is the Public API.** Those endpoints are consumed outside the product, so we need a minimum unbroken contract there first. Internal API routes are not the priority for these tests; we can add coverage for them later when there is a real need, using the same stack and suite pattern.
+
+This ADR is the **runtime and isolation** decision. How e2e test cases are written lives in [ADR-0013](./0013-api-e2e-test-suite-design.md). Current suite: [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh).
+
+## Decision
+
+Run **scheduled API e2e regression tests** (intentionally thin) via GitHub Actions against a dedicated `api-e2e` deployment of `main`, with data in a separate database on the existing staging Postgres host. Coverage starts with **Public API** endpoints; the same approach can extend to internal API e2e tests later if needed.
+
+```mermaid
+flowchart LR
+  subgraph CI["GitHub Actions (scheduled)"]
+    deploy["Deploy main → api-e2e"]
+    tests["Run e2e test script"]
+    alert["Slack #cdp-alerts"]
+    deploy --> tests
+    tests -- failure --> alert
+  end
+
+  auth0["Auth0<br/>staging issuer + audience"]
+
+  subgraph CLUSTER["Staging cluster"]
+    apiE2E["api-e2e"]
+    apiStaging["staging api"]
+    shared["Redis · OpenSearch · Temporal"]
+  end
+
+  subgraph PG["Staging Postgres host"]
+    e2edb[("crowd_api_e2e")]
+    stagingdb[("staging database")]
+  end
+
+  deploy --> apiE2E
+  tests --> auth0
+  tests -- HTTPS --> apiE2E
+  apiE2E --> e2edb
+  apiE2E --> shared
+  apiStaging --> stagingdb
+  apiStaging --> shared
+```
+
+### Stack
+
+- **`api-e2e`**: normal API deployment of `main` (same image, staging-shaped config). One deliberate difference: `CROWD_DB_DATABASE=crowd_api_e2e`. It shares staging Redis, OpenSearch, and Temporal. The e2e tests do not assert on those systems; cloning them would cost more without improving the HTTP contract signal we care about.
+- **`crowd_api_e2e`**: a separate **database name** on the existing staging Postgres host, not a second Postgres server. Same Flyway migrations as staging; no e2e-only schema.
+- **Auth0**: LFX One’s staging M2M client (issuer and audience unchanged). Only the API base URL points at `api-e2e`. CI talks to the API over HTTPS only. Secrets stay in GitHub Actions / env: never committed, never logged (`set -x` refused by the script).
+- **Scope**: API e2e tests, **Public API first** (external contract). Internal endpoints can get suites later when they need them ([ADR-0013](./0013-api-e2e-test-suite-design.md)).
+
+### What a scheduled run does
+
+1. Deploy `main` to `api-e2e`
+2. Authenticate with Auth0
+3. Seed fixtures over HTTP (`RUN_ID`-tagged names and domains)
+4. Call endpoints and assert status + body
+5. Alert `#cdp-alerts` on failure
+
+`RUN_ID` is `GITHUB_RUN_ID` in Actions, otherwise timestamp + random. Fixtures include it so runs do not collide and leftovers stay easy to spot. The suite seeds over HTTP and does not require a wiped database between runs, only a reachable `api-e2e`.
+
+### What we assert (and what we do not)
+
+- **Assert**: auth/scopes, routing, validation, sync response shape and critical flows.
+- **Do not assert**: Temporal / OpenSearch eventual outcomes. Some writes await Temporal accepting a signal before the HTTP response returns; async worker work stays out of scope for this suite.
+
+### Not a PR gate
+
+A single shared `api-e2e` cannot safely gate concurrent PRs, because deploys and DB use would step on each other. This stack is a **scheduled check of what `main` deployed**. Per-PR API e2e waits on the backlog harness.
+
+### Database reset (ops)
+
+[`reset_api_e2e_test_db.sh`](../../reset_api_e2e_test_db.sh) runs on the EC2 host used to reach staging Postgres over SSH. It creates `crowd_api_e2e` if needed, applies normal Flyway migrations, truncates tables, and seeds the default tenant.
+
+Run it after migrations land on `main`, or when leftover e2e test rows should be cleared. Keep that host’s crowd.dev checkout on latest `main` so Flyway matches the deployed `api-e2e`.
+
+Reset stays manual: the e2e database is only reachable through that EC2/SSH path. Wiring wipe/migrate into GitHub Actions would mean exposing DB access there, which is not worth it for an occasional ops step.
+
+## Alternatives Considered
+
+### Alternative 1: Hand-run e2e tests against shared staging
+
+- **Pros**: No new deploy or database.
+- **Cons**: Pollutes staging; fragile ambient fixtures; cannot expect everyone to run it.
+- **Why not**: Rejected as a team practice.
+
+### Alternative 2: Point shared staging `api` at an e2e database for the run
+
+- **Pros**: No second API deployment.
+- **Cons**: Staging users and data path become coupled to test runs; high blast radius.
+- **Why not**: Isolation requires a dedicated API process, not only a DB name.
+
+### Alternative 3: Separate Postgres server (or full clone of Redis / OpenSearch / Temporal)
+
+- **Pros**: Stronger isolation.
+- **Cons**: More infra and cost; little extra signal for HTTP contract e2e tests.
+- **Why not**: A second database name plus shared side deps is enough for this suite.
+
+### Alternative 4: Use shared `api-e2e` as a PR CI gate
+
+- **Pros**: Feedback on the branch.
+- **Cons**: Concurrent PRs overwrite deploys and data; flaky and misleading.
+- **Why not**: Needs per-PR environments or a different harness. Out of scope until the backlog work lands.
+
+### Alternative 5: Wait for the backlog PR-time API e2e harness before any automation
+
+- **Pros**: One end state only.
+- **Cons**: No automated HTTP safety net meanwhile.
+- **Why not**: Scheduled API e2e tests are an acceptable bridge.
+
+## Consequences
+
+### Positive
+
+- Catches Public API regressions on `main` sooner, without hand runs or staging DB pollution.
+- Clear ownership: runtime here; suite shape in [ADR-0013](./0013-api-e2e-test-suite-design.md); script in `.github/scripts/`.
+- Cheap isolation (DB name + small always-on `api-e2e`).
+- Public API contract covered first; internal e2e tests can be added later only when needed.
+
+### Negative
+
+- Validates deployed `main`, not unmerged PR branches.
+- Soft leftovers in `crowd_api_e2e` until someone runs the reset script.
+- Bash e2e tests will not scale forever as a full matrix (by design; keep it thin).
+
+### Risks
+
+- `api-e2e` drifts from staging config → treat it as a clone with only `CROWD_DB_DATABASE` different; deploy `main` on the schedule.
+- Flyway on the EC2 checkout lags `main` → reset script refuses to run until the checkout is updated.
+- Pressure to assert async side effects → push that to worker/workflow tests; keep this suite on the sync HTTP contract.
+- Backlog PR-time API e2e delayed → these scheduled e2e tests remain the safety net; that is acceptable.
+- Scope creep into internal APIs before Public coverage is solid → keep Public as the focus unless there is a concrete need.
diff --git a/docs/adr/0013-api-e2e-test-suite-design.md b/docs/adr/0013-api-e2e-test-suite-design.md
new file mode 100644
index 0000000000..8c2a21cde7
--- /dev/null
+++ b/docs/adr/0013-api-e2e-test-suite-design.md
@@ -0,0 +1,74 @@
+# ADR-0013: API e2e test suite design
+
+**Date**: 2026-07-24
+**Status**: accepted
+**Deciders**: Yeganathan S
+
+## Context
+
+API e2e tests need a maintainable HTTP suite that can grow as endpoints are added. For now the suite covers the **Public API** (see [ADR-0012](./0012-api-e2e-test-architecture.md)); the same writing pattern should hold if we later add internal API e2e tests when there is a real need.
+
+Without a shared structure, suites tend to split by HTTP method, accumulate one-off asserts, and become hard to extend or review. We want a thin, boring pattern: shared helpers, clear grouping, and an obvious place to add the next resource. This is how we write durable e2e cases until a fuller PR-time API e2e harness exists (backlog).
+
+This ADR is the **suite design** (how tests are written). Runtime, isolation, scheduled CI, and ops live in [ADR-0012](./0012-api-e2e-test-architecture.md).
+
+## Decision
+
+Implement API e2e tests as HTTP bash script(s) with this structure. Current entrypoint: [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh) (Public API). New surfaces should reuse this pattern (same script or additional scripts), not invent a parallel style.
+
+The suite is **regression-oriented and intentionally thin**: contract and critical flows over HTTP, not a full matrix of edge cases.
+
+| Layer | Role |
+| --- | --- |
+| Helpers | `api` (call), `check` (soft status + body preds), `require` (hard fail for seed) |
+| Seed | Create shared fixtures once per run over HTTP |
+| Suites | One `suite_*` per **resource path**, not per HTTP method |
+| Cases | One exchange: `api` then `check`. Stateful resources stay ordered as a short story |
+| Registration | Call each suite from `main` |
+
+**Rules of thumb**
+
+- Group by resource (`/organizations`, `/members/:id/identities`, …). Put GET and POST for the same path in the same suite.
+- Prefer one meaningful `check` per request over many micro-asserts.
+- Cover the contract and critical flows. Leave deep normalization / matrix cases to unit or focused contract tests.
+- Keep seed HTTP-only. Suites that need non-API fixtures (e.g. activity-backed projects) may cover the route with empty/error paths until a fixture exists.
+- Name people and orgs like real data so failures read naturally.
+
+## Alternatives Considered
+
+### Alternative 1: One suite per HTTP method (`suite_get_*`, `suite_post_*`)
+
+- **Pros**: Mirrors OpenAPI operation IDs one-to-one.
+- **Cons**: Splits one resource across files/functions; harder to see the full surface; encourages duplicate setup.
+- **Why not**: Resource path is the stable unit as the API grows.
+
+### Alternative 2: Flat table of request/response cases only
+
+- **Pros**: Very uniform; easy code-gen later.
+- **Cons**: Poor fit for stateful flows (create → list → update → verify).
+- **Why not**: We need both independent cases and short ordered stories.
+
+### Alternative 3: No shared structure (each contributor invents a style)
+
+- **Pros**: Maximum local freedom.
+- **Cons**: Inconsistent quality; expensive to review and extend.
+- **Why not**: A thin shared pattern scales better than ad-hoc scripts.
+
+## Consequences
+
+### Positive
+
+- Adding an endpoint is: open (or create) the resource suite, add `api` + `check`, register in `main` if new.
+- Reviews focus on cases, not harness inventiveness.
+- Soft `check` keeps the run going so one failure does not hide the rest.
+- Public API suites land first; internal API can follow the same rules later without a new design debate.
+
+### Negative
+
+- Stateful suites are order-dependent; a mid-story failure can cascade.
+- Bash is less ergonomic than a typed test runner for large suites.
+
+### Risks
+
+- Script grows past a comfortable size → split helpers vs suites, or one file per resource / surface, without changing the grouping rule.
+- Pressure to turn e2e tests into a full matrix → push depth to unit/contract tests; keep this suite thin.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index cc3e7bbf26..00e36a152d 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -18,6 +18,8 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
 | [ADR-0009](./0009-packagist-worker-design-decisions.md)        | Packagist worker — design decisions                                                                  | accepted | 2026-07-13 |
 | [ADR-0010](./0010-security-contacts-worker.md)                 | Security contacts — tiered extraction, confidence scoring, and Temporal batch ingestion              | accepted | 2026-07-21 |
 | [ADR-0011](./0011-mailinglist-skip-unparseable-dates.md)       | Skip mailing list activities with unparseable/implausible dates                                      | accepted | 2026-07-19 |
+| [ADR-0012](./0012-api-e2e-test-architecture.md)                | API e2e test architecture                                                                            | accepted | 2026-07-25 |
+| [ADR-0013](./0013-api-e2e-test-suite-design.md)                | API e2e test suite design                                                                            | accepted | 2026-07-24 |
 
 ## Why ADRs?
 
diff --git a/scripts/builders/backend.env b/scripts/builders/backend.env
index ad27ed5612..d49a53b4ef 100644
--- a/scripts/builders/backend.env
+++ b/scripts/builders/backend.env
@@ -1,4 +1,4 @@
 DOCKERFILE="./services/docker/Dockerfile.backend"
 CONTEXT="../"
 REPO="sjc.ocir.io/axbydjxa5zuh/backend"
-SERVICES="api job-generator"
\ No newline at end of file
+SERVICES="api api-e2e job-generator"
\ No newline at end of file

From 9e181236a60d3ddd020d8195c1d648455350d08d Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:35:09 +0530
Subject: [PATCH 02/12] fix: improve error logging in public API e2e tests

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index 53255b7398..77beb5cc20 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -69,12 +69,20 @@ api() {
   BODY="$(printf '%s' "$response" | sed '$d')"
 }
 
+# Print a short, non-secret snippet of the last res on failure (never tokens).
+log_fail_body() {
+  local snippet
+  snippet="$(jq -c 'if type == "object" then del(.access_token, .refresh_token, .id_token, .client_secret) else . end' <<<"$BODY" 2>/dev/null || printf '%s' "$BODY")"
+  snippet="${snippet:0:500}"
+  printf '  body: %s\n' "$snippet"
+}
+
 check() {
   local name=$1 expected=$2
   shift 2
   if [[ $HTTP_CODE != "$expected" ]]; then
     printf '  FAIL  %s — expected HTTP %s, got %s\n' "$name" "$expected" "$HTTP_CODE"
-    jq . <<<"$BODY" 2>/dev/null || printf '%s\n' "$BODY"
+    log_fail_body
     FAIL=$((FAIL + 1))
     return
   fi
@@ -82,7 +90,7 @@ check() {
   for expr in "$@"; do
     if ! jq -e "$expr" >/dev/null 2>&1 <<<"$BODY"; then
       printf '  FAIL  %s (%s)\n' "$name" "$expr"
-      jq . <<<"$BODY" 2>/dev/null || printf '%s\n' "$BODY"
+      log_fail_body
       FAIL=$((FAIL + 1))
       return
     fi

From 766c5157cf8de6b4457537bfe4d5724bbc2f3af6 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Sun, 26 Jul 2026 00:01:55 +0530
Subject: [PATCH 03/12] fix: resolve pr review comments

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh    |  4 +-
 .github/workflows/api-e2e-tests.yml        | 48 ++++++++++++++--------
 docs/adr/0012-api-e2e-test-architecture.md | 10 ++---
 docs/adr/0013-api-e2e-test-suite-design.md | 11 +++--
 4 files changed, 42 insertions(+), 31 deletions(-)

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index 77beb5cc20..e74c6dbdb8 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -5,10 +5,10 @@
 # Database setup/reset is handled externally.
 #
 # Required environment variables (same names as GitHub Actions secrets/vars):
-#   AUTH0_API_E2E_CLIENT_ID
-#   AUTH0_API_E2E_CLIENT_SECRET
 #   AUTH0_STAGING_AUDIENCE
 #   AUTH0_STAGING_ISSUER
+#   AUTH0_API_E2E_CLIENT_ID
+#   AUTH0_API_E2E_CLIENT_SECRET
 #   CDP_API_E2E_BASE_URL
 #
 # Do not enable `set -x`; requests include sensitive credentials.
diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
index afc3b7dd5d..e982772afc 100644
--- a/.github/workflows/api-e2e-tests.yml
+++ b/.github/workflows/api-e2e-tests.yml
@@ -9,20 +9,10 @@ concurrency:
   group: api-e2e-tests
   cancel-in-progress: false
 
+permissions:
+  contents: read
+
 env:
-  CLOUD_ENV: lf-oracle-staging
-  ORACLE_DOCKER_USERNAME: ${{ secrets.ORACLE_DOCKER_USERNAME }}
-  ORACLE_DOCKER_PASSWORD: ${{ secrets.ORACLE_DOCKER_PASSWORD }}
-  ORACLE_USER: ${{ secrets.ORACLE_USER }}
-  ORACLE_TENANT: ${{ secrets.ORACLE_TENANT }}
-  ORACLE_REGION: ${{ secrets.ORACLE_REGION }}
-  ORACLE_FINGERPRINT: ${{ secrets.ORACLE_FINGERPRINT }}
-  ORACLE_KEY: ${{ secrets.ORACLE_KEY }}
-  ORACLE_CLUSTER: ${{ secrets.ORACLE_STAGING_CLUSTER }}
-  AUTH0_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_API_E2E_CLIENT_ID }}
-  AUTH0_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_API_E2E_CLIENT_SECRET }}
-  AUTH0_STAGING_AUDIENCE: ${{ vars.AUTH0_STAGING_AUDIENCE }}
-  AUTH0_STAGING_ISSUER: ${{ vars.AUTH0_STAGING_ISSUER }}
   CDP_API_E2E_BASE_URL: ${{ vars.CDP_API_E2E_BASE_URL }}
 
 jobs:
@@ -35,22 +25,39 @@ jobs:
           ref: main
 
       - name: Install OCI
+        env:
+          OCI_CLI_INSTALL_REF: v3.89.3
+          OCI_CLI_VERSION: 3.89.3
         run: |
-          curl -L https://raw.githubusercontent.com/oracle/oci-cli/master/scripts/install/install.sh > install.sh
+          set -euo pipefail
+          curl -fsSL "https://raw.githubusercontent.com/oracle/oci-cli/${OCI_CLI_INSTALL_REF}/scripts/install/install.sh" -o install.sh
           chmod +x install.sh
-          ./install.sh --accept-all-defaults
-          echo "OCI_CLI_DIR=/home/runner/bin" >> $GITHUB_ENV
+          ./install.sh --accept-all-defaults --oci-cli-version "${OCI_CLI_VERSION}"
+          echo "OCI_CLI_DIR=/home/runner/bin" >> "$GITHUB_ENV"
 
       - name: Update PATH
-        run: echo "${{ env.OCI_CLI_DIR }}" >> $GITHUB_PATH
+        run: echo "${{ env.OCI_CLI_DIR }}" >> "$GITHUB_PATH"
 
-      - uses: ./.github/actions/node/builder
+      - name: Deploy api-e2e
+        uses: ./.github/actions/node/builder
+        env:
+          CLOUD_ENV: lf-oracle-staging
+          ORACLE_DOCKER_USERNAME: ${{ secrets.ORACLE_DOCKER_USERNAME }}
+          ORACLE_DOCKER_PASSWORD: ${{ secrets.ORACLE_DOCKER_PASSWORD }}
+          ORACLE_USER: ${{ secrets.ORACLE_USER }}
+          ORACLE_TENANT: ${{ secrets.ORACLE_TENANT }}
+          ORACLE_REGION: ${{ secrets.ORACLE_REGION }}
+          ORACLE_FINGERPRINT: ${{ secrets.ORACLE_FINGERPRINT }}
+          ORACLE_KEY: ${{ secrets.ORACLE_KEY }}
+          ORACLE_CLUSTER: ${{ secrets.ORACLE_STAGING_CLUSTER }}
         with:
           services: api-e2e
 
       - name: Wait for api-e2e
         run: |
           set -euo pipefail
+          # Builder only runs kubectl set image; wait for the new revision before health.
+          kubectl rollout status deployment/api-e2e-dpl --timeout=600s
           health_url="${CDP_API_E2E_BASE_URL%/v1}/health"
           for i in $(seq 1 36); do
             curl -fsS --max-time 10 "$health_url" >/dev/null && exit 0
@@ -60,4 +67,9 @@ jobs:
           exit 1
 
       - name: Run Public API e2e tests
+        env:
+          AUTH0_STAGING_AUDIENCE: ${{ vars.AUTH0_STAGING_AUDIENCE }}
+          AUTH0_STAGING_ISSUER: ${{ vars.AUTH0_STAGING_ISSUER }}
+          AUTH0_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_API_E2E_CLIENT_ID }}
+          AUTH0_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_API_E2E_CLIENT_SECRET }}
         run: bash .github/scripts/public-api-e2e-tests.sh
diff --git a/docs/adr/0012-api-e2e-test-architecture.md b/docs/adr/0012-api-e2e-test-architecture.md
index 99ef1010a1..0878948997 100644
--- a/docs/adr/0012-api-e2e-test-architecture.md
+++ b/docs/adr/0012-api-e2e-test-architecture.md
@@ -6,13 +6,13 @@
 
 ## Context
 
-We need to catch regressions on the API on `main` sooner (auth, routing, validation, and the sync HTTP response contract) without waiting for a consumer to hit a break. Relying on manually running the script every time is hard and not reliable. It pollutes staging data, depends on ambient fixtures, and does not work as a team habit.
+We want to catch API regressions on main sooner (auth, routing, validation, and the synchronous HTTP response contract) instead of waiting for a consumer to hit a problem. Manually running the test script is easy to forget, pollutes staging data, depends on existing fixtures, and is not something we can rely on as a team.
 
-A fuller API e2e harness in the server test setup (PR CI, isolated fixtures, typed asserts) is planned and sits in the backlog. Until that is ready, we need a short-term automated check that is isolated from staging data and cheap to operate.
+A more complete API e2e harness in the server test setup (PR CI, isolated fixtures, typed assertions) is planned for future work. Until then, we need a simple automated check that runs against an isolated environment and gives us confidence that the deployed API is still working as expected.
 
-**Focus for now is the Public API.** Those endpoints are consumed outside the product, so we need a minimum unbroken contract there first. Internal API routes are not the priority for these tests; we can add coverage for them later when there is a real need, using the same stack and suite pattern.
+The first test suite covers the Public API. These endpoints are the external contract our consumers rely on, so they are the best place to start. We can add more API e2e suites over time using the same runtime and suite pattern as needed.
 
-This ADR is the **runtime and isolation** decision. How e2e test cases are written lives in [ADR-0013](./0013-api-e2e-test-suite-design.md). Current suite: [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh).
+This ADR covers the runtime and isolation architecture for API e2e tests. The design of the test suites themselves is covered in [ADR-0013](./0013-api-e2e-test-suite-design.md). The current suite lives in [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh).
 
 ## Decision
 
@@ -78,7 +78,7 @@ A single shared `api-e2e` cannot safely gate concurrent PRs, because deploys and
 
 ### Database reset (ops)
 
-[`reset_api_e2e_test_db.sh`](../../reset_api_e2e_test_db.sh) runs on the EC2 host used to reach staging Postgres over SSH. It creates `crowd_api_e2e` if needed, applies normal Flyway migrations, truncates tables, and seeds the default tenant.
+`reset_api_e2e_test_db.sh` runs on the EC2 host used to reach staging Postgres over SSH. It creates `crowd_api_e2e` if needed, applies normal Flyway migrations, truncates tables, and seeds the default tenant.
 
 Run it after migrations land on `main`, or when leftover e2e test rows should be cleared. Keep that host’s crowd.dev checkout on latest `main` so Flyway matches the deployed `api-e2e`.
 
diff --git a/docs/adr/0013-api-e2e-test-suite-design.md b/docs/adr/0013-api-e2e-test-suite-design.md
index 8c2a21cde7..3eba457359 100644
--- a/docs/adr/0013-api-e2e-test-suite-design.md
+++ b/docs/adr/0013-api-e2e-test-suite-design.md
@@ -6,17 +6,17 @@
 
 ## Context
 
-API e2e tests need a maintainable HTTP suite that can grow as endpoints are added. For now the suite covers the **Public API** (see [ADR-0012](./0012-api-e2e-test-architecture.md)); the same writing pattern should hold if we later add internal API e2e tests when there is a real need.
+API e2e tests need a maintainable HTTP suite pattern that can grow as endpoints are added. Without shared structure, suites tend to split by HTTP method, accumulate one-off asserts, and become hard to extend or review.
 
-Without a shared structure, suites tend to split by HTTP method, accumulate one-off asserts, and become hard to extend or review. We want a thin, boring pattern: shared helpers, clear grouping, and an obvious place to add the next resource. This is how we write durable e2e cases until a fuller PR-time API e2e harness exists (backlog).
+We want a thin, boring pattern: shared helpers, clear grouping, and an obvious place to add the next resource. This is how we write durable e2e cases until a fuller PR-time API e2e harness exists (backlog).
 
-This ADR is the **suite design** (how tests are written). Runtime, isolation, scheduled CI, and ops live in [ADR-0012](./0012-api-e2e-test-architecture.md).
+This ADR is the **suite design** (how tests are written). Runtime, isolation, scheduled CI, which surfaces we cover, and ops live in [ADR-0012](./0012-api-e2e-test-architecture.md).
 
 ## Decision
 
-Implement API e2e tests as HTTP bash script(s) with this structure. Current entrypoint: [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh) (Public API). New surfaces should reuse this pattern (same script or additional scripts), not invent a parallel style.
+Implement API e2e tests as HTTP bash script(s) with this structure. Current entrypoint: [`.github/scripts/public-api-e2e-tests.sh`](../../.github/scripts/public-api-e2e-tests.sh). New suites should reuse this pattern (same script or additional scripts), not invent a parallel style.
 
-The suite is **regression-oriented and intentionally thin**: contract and critical flows over HTTP, not a full matrix of edge cases.
+The suite is **thin**: assert the HTTP contract and critical flows so regressions show up early. Leave exhaustive edge-case matrices to unit or focused contract tests.
 
 | Layer | Role |
 | --- | --- |
@@ -61,7 +61,6 @@ The suite is **regression-oriented and intentionally thin**: contract and critic
 - Adding an endpoint is: open (or create) the resource suite, add `api` + `check`, register in `main` if new.
 - Reviews focus on cases, not harness inventiveness.
 - Soft `check` keeps the run going so one failure does not hide the rest.
-- Public API suites land first; internal API can follow the same rules later without a new design debate.
 
 ### Negative
 

From 71617ab821620f9389284e3e4203291f8d8d5907 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Sun, 26 Jul 2026 19:22:35 +0530
Subject: [PATCH 04/12] ci: improve API e2e tests workflow with Slack failure
 notifications

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/workflows/api-e2e-tests.yml | 36 ++++++++++++++++++++++++++++-
 1 file changed, 35 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
index e982772afc..2029cffdfa 100644
--- a/.github/workflows/api-e2e-tests.yml
+++ b/.github/workflows/api-e2e-tests.yml
@@ -53,7 +53,7 @@ jobs:
         with:
           services: api-e2e
 
-      - name: Wait for api-e2e
+      - name: Wait for api-e2e service to be ready
         run: |
           set -euo pipefail
           # Builder only runs kubectl set image; wait for the new revision before health.
@@ -73,3 +73,37 @@ jobs:
           AUTH0_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_API_E2E_CLIENT_ID }}
           AUTH0_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_API_E2E_CLIENT_SECRET }}
         run: bash .github/scripts/public-api-e2e-tests.sh
+
+      - name: Notify Slack on failure
+        if: failure()
+        env:
+          CDP_ALERTS_SLACK_WEBHOOK_URL: ${{ secrets.CDP_ALERTS_SLACK_WEBHOOK_URL }}
+          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+          EVENT_NAME: ${{ github.event_name }}
+        run: |
+          set -euo pipefail
+          payload="$(jq -n \
+            --arg run_url "$RUN_URL" \
+            --arg event "$EVENT_NAME" \
+            '{
+              blocks: [
+                {
+                  type: "header",
+                  text: {
+                    type: "plain_text",
+                    text: ":rotating_light: Public API e2e tests failed",
+                    emoji: true
+                  }
+                },
+                {
+                  type: "section",
+                  text: {
+                    type: "mrkdwn",
+                    text: ("*Workflow:* `API e2e tests`\n*Event:* `" + $event + "`\n*Run:* <" + $run_url + "|View run>")
+                  }
+                }
+              ]
+            }')"
+          curl -fsS -X POST "$CDP_ALERTS_SLACK_WEBHOOK_URL" \
+            -H 'Content-Type: application/json' \
+            -d "$payload"

From 8b17d5d2e28ace43db3c2b141be93bbbba14b3ec Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Sun, 26 Jul 2026 23:20:07 +0530
Subject: [PATCH 05/12] fix: resolve pr review comments

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh    | 9 +++++++--
 backend/src/api/public/openapi.yaml        | 3 +--
 docs/adr/0012-api-e2e-test-architecture.md | 2 +-
 3 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index e74c6dbdb8..60a11acd28 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -56,9 +56,13 @@ die() {
   exit 1
 }
 
+CURL_CONNECT_TIMEOUT=10
+CURL_MAX_TIME=30
+
 api() {
   local method=$1 path=$2 body=${3-}
-  local -a args=(-sS -w '\n%{http_code}' -X "$method" "${CDP_API_E2E_BASE_URL}${path}"
+  local -a args=(-sS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME"
+    -w '\n%{http_code}' -X "$method" "${CDP_API_E2E_BASE_URL}${path}"
     -H 'Content-Type: application/json'
     -H "Authorization: Bearer ${TOKEN}")
   [[ -n $body ]] && args+=(-d "$body")
@@ -110,7 +114,8 @@ json() {
 
 fetch_token() {
   local response
-  response="$(curl -sS -X POST "${AUTH0_STAGING_ISSUER}/oauth/token" \
+  response="$(curl -sS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" \
+    -X POST "${AUTH0_STAGING_ISSUER}/oauth/token" \
     -H 'Content-Type: application/json' \
     -d "$(json \
       --arg client_id "$AUTH0_API_E2E_CLIENT_ID" \
diff --git a/backend/src/api/public/openapi.yaml b/backend/src/api/public/openapi.yaml
index 3be905cd7c..931f5934f4 100644
--- a/backend/src/api/public/openapi.yaml
+++ b/backend/src/api/public/openapi.yaml
@@ -855,7 +855,6 @@ paths:
                 - name
                 - domain
                 - source
-                - logo
               properties:
                 name:
                   type: string
@@ -872,7 +871,7 @@ paths:
                 logo:
                   type: string
                   format: uri
-                  description: URL of the organization's logo.
+                  description: Optional URL of the organization's logo.
             example:
               name: Acme Corp
               domain: acme.com
diff --git a/docs/adr/0012-api-e2e-test-architecture.md b/docs/adr/0012-api-e2e-test-architecture.md
index 0878948997..39691f035a 100644
--- a/docs/adr/0012-api-e2e-test-architecture.md
+++ b/docs/adr/0012-api-e2e-test-architecture.md
@@ -65,7 +65,7 @@ flowchart LR
 4. Call endpoints and assert status + body
 5. Alert `#cdp-alerts` on failure
 
-`RUN_ID` is `GITHUB_RUN_ID` in Actions, otherwise timestamp + random. Fixtures include it so runs do not collide and leftovers stay easy to spot. The suite seeds over HTTP and does not require a wiped database between runs, only a reachable `api-e2e`.
+`RUN_ID` is `GITHUB_RUN_ID` (or a local timestamp) plus a `${RANDOM}` suffix. The suffix matters because workflow reruns reuse the same `GITHUB_RUN_ID`. Fixtures include it so runs do not collide and leftovers stay easy to spot. The suite seeds over HTTP and does not require a wiped database between runs, only a reachable `api-e2e`.
 
 ### What we assert (and what we do not)
 

From 5ef400653256cb26de8a56a0df5fad8a30c3a6c3 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Sun, 26 Jul 2026 23:36:26 +0530
Subject: [PATCH 06/12] fix: resolve pr review comments

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 docs/adr/0012-api-e2e-test-architecture.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/adr/0012-api-e2e-test-architecture.md b/docs/adr/0012-api-e2e-test-architecture.md
index 39691f035a..42b33011d1 100644
--- a/docs/adr/0012-api-e2e-test-architecture.md
+++ b/docs/adr/0012-api-e2e-test-architecture.md
@@ -6,7 +6,7 @@
 
 ## Context
 
-We want to catch API regressions on main sooner (auth, routing, validation, and the synchronous HTTP response contract) instead of waiting for a consumer to hit a problem. Manually running the test script is easy to forget, pollutes staging data, depends on existing fixtures, and is not something we can rely on as a team.
+We want to catch API regressions on main sooner (routing, validation, and the synchronous HTTP response contract with a valid token) instead of waiting for a consumer to hit a problem. Manually running the test script is easy to forget, pollutes staging data, depends on existing fixtures, and is not something we can rely on as a team.
 
 A more complete API e2e harness in the server test setup (PR CI, isolated fixtures, typed assertions) is planned for future work. Until then, we need a simple automated check that runs against an isolated environment and gives us confidence that the deployed API is still working as expected.
 
@@ -69,7 +69,7 @@ flowchart LR
 
 ### What we assert (and what we do not)
 
-- **Assert**: auth/scopes, routing, validation, sync response shape and critical flows.
+- **Assert**: routing, validation, sync response shape and critical flows using a valid fully scoped token.
 - **Do not assert**: Temporal / OpenSearch eventual outcomes. Some writes await Temporal accepting a signal before the HTTP response returns; async worker work stays out of scope for this suite.
 
 ### Not a PR gate

From da647c20aae3b5fa545b0c256c5ed7a9cdafd874 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Mon, 27 Jul 2026 13:04:53 +0530
Subject: [PATCH 07/12] fix: rename env variables for better clarity

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh | 12 ++++++------
 .github/workflows/api-e2e-tests.yml     |  4 ++--
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index 60a11acd28..b09fe28cda 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -7,8 +7,8 @@
 # Required environment variables (same names as GitHub Actions secrets/vars):
 #   AUTH0_STAGING_AUDIENCE
 #   AUTH0_STAGING_ISSUER
-#   AUTH0_API_E2E_CLIENT_ID
-#   AUTH0_API_E2E_CLIENT_SECRET
+#   AUTH0_STAGING_API_E2E_CLIENT_ID
+#   AUTH0_STAGING_API_E2E_CLIENT_SECRET
 #   CDP_API_E2E_BASE_URL
 #
 # Do not enable `set -x`; requests include sensitive credentials.
@@ -35,8 +35,8 @@ for cmd in curl jq; do
   }
 done
 
-: "${AUTH0_API_E2E_CLIENT_ID:?set AUTH0_API_E2E_CLIENT_ID}"
-: "${AUTH0_API_E2E_CLIENT_SECRET:?set AUTH0_API_E2E_CLIENT_SECRET}"
+: "${AUTH0_STAGING_API_E2E_CLIENT_ID:?set AUTH0_STAGING_API_E2E_CLIENT_ID}"
+: "${AUTH0_STAGING_API_E2E_CLIENT_SECRET:?set AUTH0_STAGING_API_E2E_CLIENT_SECRET}"
 : "${AUTH0_STAGING_AUDIENCE:?set AUTH0_STAGING_AUDIENCE}"
 : "${AUTH0_STAGING_ISSUER:?set AUTH0_STAGING_ISSUER}"
 : "${CDP_API_E2E_BASE_URL:?set CDP_API_E2E_BASE_URL}"
@@ -118,8 +118,8 @@ fetch_token() {
     -X POST "${AUTH0_STAGING_ISSUER}/oauth/token" \
     -H 'Content-Type: application/json' \
     -d "$(json \
-      --arg client_id "$AUTH0_API_E2E_CLIENT_ID" \
-      --arg client_secret "$AUTH0_API_E2E_CLIENT_SECRET" \
+      --arg client_id "$AUTH0_STAGING_API_E2E_CLIENT_ID" \
+      --arg client_secret "$AUTH0_STAGING_API_E2E_CLIENT_SECRET" \
       --arg audience "$AUTH0_STAGING_AUDIENCE" \
       '{grant_type:"client_credentials",client_id:$client_id,client_secret:$client_secret,audience:$audience}')")"
 
diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
index 2029cffdfa..3433a6bcd5 100644
--- a/.github/workflows/api-e2e-tests.yml
+++ b/.github/workflows/api-e2e-tests.yml
@@ -70,8 +70,8 @@ jobs:
         env:
           AUTH0_STAGING_AUDIENCE: ${{ vars.AUTH0_STAGING_AUDIENCE }}
           AUTH0_STAGING_ISSUER: ${{ vars.AUTH0_STAGING_ISSUER }}
-          AUTH0_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_API_E2E_CLIENT_ID }}
-          AUTH0_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_API_E2E_CLIENT_SECRET }}
+          AUTH0_STAGING_API_E2E_CLIENT_ID: ${{ secrets.AUTH0_STAGING_API_E2E_CLIENT_ID }}
+          AUTH0_STAGING_API_E2E_CLIENT_SECRET: ${{ secrets.AUTH0_STAGING_API_E2E_CLIENT_SECRET }}
         run: bash .github/scripts/public-api-e2e-tests.sh
 
       - name: Notify Slack on failure

From 34b283e4be180bb811637c710ea98eff23706ce4 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:26:28 +0530
Subject: [PATCH 08/12] fix: minor nitpicks and agent skills

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .claude/skills/write-api-e2e-tests/SKILL.md | 66 +++++++++++++++
 .claude/skills/write-unit-tests/SKILL.md    | 71 ++++++++++++++++
 .github/scripts/public-api-e2e-tests.sh     | 90 ++++++++++-----------
 .github/workflows/api-e2e-tests.yml         |  2 +-
 docs/adr/0008-how-we-write-unit-tests.md    | 14 ++++
 docs/adr/0013-api-e2e-test-suite-design.md  |  2 +-
 6 files changed, 198 insertions(+), 47 deletions(-)
 create mode 100644 .claude/skills/write-api-e2e-tests/SKILL.md
 create mode 100644 .claude/skills/write-unit-tests/SKILL.md

diff --git a/.claude/skills/write-api-e2e-tests/SKILL.md b/.claude/skills/write-api-e2e-tests/SKILL.md
new file mode 100644
index 0000000000..6c3d8c571a
--- /dev/null
+++ b/.claude/skills/write-api-e2e-tests/SKILL.md
@@ -0,0 +1,66 @@
+---
+name: write-api-e2e-tests
+description: >
+  Write API end-to-end tests. Use when adding or changing API endpoints, or when
+  the user asks for API e2e, smoke, or contract tests.
+allowed-tools: Bash, Read, Glob, Grep, Edit, Write, AskUserQuestion
+---
+
+# Write API end-to-end tests
+
+Write or extend API end-to-end tests.
+
+## When to use
+
+- New or changed API endpoints.
+- User asks for API e2e, smoke, or contract tests.
+- Critical API behaviour needs regression coverage.
+
+## When not to use
+
+- Domain or SQL correctness → use `write-unit-tests`.
+- Temporal, OpenSearch, or other eventual side effects outside the documented API e2e scope.
+- Creating a new testing framework or suite style.
+
+## Source of truth
+
+Read before writing. Follow these ADRs and existing suite structure; do not
+invent a parallel testing style.
+
+- [ADR-0012](docs/adr/0012-api-e2e-test-architecture.md) — runtime, isolation, supported surfaces, scope, and assertions.
+- [ADR-0013](docs/adr/0013-api-e2e-test-suite-design.md) — suite organisation, helpers, and conventions.
+
+Current default entrypoint:
+
+- `.github/scripts/public-api-e2e-tests.sh`
+
+## Workflow
+
+1. Identify the API surface. Default to Public API unless the user specifies otherwise.
+2. Read ADR-0012 and ADR-0013.
+3. Add or extend the appropriate suite and register it if required.
+4. Run the affected suite locally and fix failures until green.
+5. If required fixtures cannot be created through the API, prefer testing supported scenarios and explain any coverage gaps instead of seeding the database directly.
+6. Suggest production testability improvements only when they make the API easier to test, and ask before changing production code.
+
+## Run
+
+Export the environment variables required by the suite entrypoint.
+
+```bash
+bash .github/scripts/public-api-e2e-tests.sh
+```
+
+Refer to ADR-0012 and the suite entrypoint for environment setup, reset behaviour,
+and local development workflows.
+
+## Guardrails
+
+- Keep tests focused on observable API behaviour.
+
+## Output
+
+- Suites and cases added
+- How to re-run
+- Coverage gaps, if any
+- Optional testability suggestions
diff --git a/.claude/skills/write-unit-tests/SKILL.md b/.claude/skills/write-unit-tests/SKILL.md
new file mode 100644
index 0000000000..c8d3082480
--- /dev/null
+++ b/.claude/skills/write-unit-tests/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: write-unit-tests
+description: >
+  Write focused Vitest unit tests. Use when adding or improving unit tests for
+  business logic, data access, common services, or other server modules.
+allowed-tools: Bash, Read, Glob, Grep, Edit, Write, AskUserQuestion
+---
+
+# Write unit tests
+
+Write focused unit tests that follow the project's testing conventions.
+
+## When to use
+
+- User asks to add or improve unit tests.
+- A change touches high-blast-radius logic (affiliations, merges, identity resolution, timelines, inference).
+- A PR needs confidence in a pure or Postgres-backed function.
+
+## When not to use
+
+- Public or HTTP contract coverage → use `write-api-e2e-tests`.
+- Temporal, Redis, or OpenSearch fixtures (not available yet).
+- Broad "increase coverage %" requests without a clear unit under test.
+
+## Source of truth
+
+Read before writing. Follow these ADRs; do not invent a parallel testing style.
+
+- [ADR-0008](docs/adr/0008-how-we-write-unit-tests.md) — scenarios, `describe` grouping, assertions, mocking, and shared setup.
+- [ADR-0007](docs/adr/0007-test-factory-primitives-and-defaults.md) — factories and defaults.
+
+## Workflow
+
+1. Identify the unit under test (one function or decision path). Colocate tests as `<file>.test.ts`.
+2. Read ADR-0007 and ADR-0008. Skim the nearest existing test in the same area if one exists.
+3. Compose fixtures using `@crowd/test-kit` (`withQx` for Postgres-backed tests; factories and opt-in defaults per ADR-0007).
+4. Write focused scenarios following ADR-0008 (grouping, naming, assertions, and mocking).
+5. Run the affected tests and fix failures until green.
+6. If production code is difficult to test, suggest a small testability seam and ask before changing production code.
+
+## Run
+
+Start the test database when needed:
+
+```bash
+./scripts/cli scaffold up-test
+```
+
+Run a focused test file:
+
+```bash
+pnpm test:server -- path/to/file.test.ts
+```
+
+Optional:
+
+```bash
+pnpm test:changed
+pnpm test:watch -- path/to/file.test.ts
+```
+
+## Guardrails
+
+- Prefer critical behaviours over trivial getters, setters, and thin wrappers.
+- Keep production behaviour unchanged unless the user explicitly asks for a testability improvement.
+
+## Output
+
+- Scenarios covered
+- How to re-run
+- Optional testability suggestions
diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index b09fe28cda..b6b8ca5dc2 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -60,9 +60,9 @@ CURL_CONNECT_TIMEOUT=10
 CURL_MAX_TIME=30
 
 api() {
-  local method=$1 path=$2 body=${3-}
+  local version=$1 method=$2 path=$3 body=${4-}
   local -a args=(-sS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME"
-    -w '\n%{http_code}' -X "$method" "${CDP_API_E2E_BASE_URL}${path}"
+    -w '\n%{http_code}' -X "$method" "${CDP_API_E2E_BASE_URL}/${version}${path}"
     -H 'Content-Type: application/json'
     -H "Authorization: Bearer ${TOKEN}")
   [[ -n $body ]] && args+=(-d "$body")
@@ -144,19 +144,19 @@ PERSON_ID=""
 seed() {
   echo "=== seed (${RUN_ID}) ==="
 
-  api POST /organizations "$(json \
+  api v1 POST /organizations "$(json \
     --arg name "$ACME_NAME" --arg domain "$ACME_DOMAIN" --arg source "$SOURCE" \
     '{name:$name, domain:$domain, source:$source}')"
   require 201 "create Acme"
   ACME_ID="$(jq -r '.id' <<<"$BODY")"
 
-  api POST /organizations "$(json \
+  api v1 POST /organizations "$(json \
     --arg name "$GLOBEX_NAME" --arg domain "$GLOBEX_DOMAIN" --arg source "$SOURCE" \
     '{name:$name, domain:$domain, source:$source}')"
   require 201 "create Globex"
   GLOBEX_ID="$(jq -r '.id' <<<"$BODY")"
 
-  api POST /members "$(json \
+  api v1 POST /members "$(json \
     --arg lfid "$PERSON_LFID" --arg by "$VERIFIED_BY" --arg name "$PERSON_NAME" '{
       displayName: $name,
       identities: [{
@@ -186,35 +186,35 @@ suite_organizations() {
   local name_q
   name_q="$(jq -nr --arg n "$ACME_NAME" '$n|@uri')"
 
-  api GET "/organizations?domain=${ACME_DOMAIN}"
+  api v1 GET "/organizations?domain=${ACME_DOMAIN}"
   check "GET find Acme by domain" 200 \
     ".id == \"$ACME_ID\"" \
     ".name == \"$ACME_NAME\""
 
-  api GET "/organizations?name=${name_q}"
+  api v1 GET "/organizations?name=${name_q}"
   check "GET find Acme by name" 200 ".id == \"$ACME_ID\""
 
-  api GET "/organizations?name=${name_q}&domain=${ACME_DOMAIN}"
+  api v1 GET "/organizations?name=${name_q}&domain=${ACME_DOMAIN}"
   check "GET find Acme by name+domain" 200 ".id == \"$ACME_ID\""
 
-  api GET "/organizations?name=${name_q}&domain=example.com"
+  api v1 GET "/organizations?name=${name_q}&domain=example.com"
   check "GET name/domain mismatch" 404
 
-  api GET "/organizations"
+  api v1 GET "/organizations"
   check "GET missing params" 400
 
-  api GET "/organizations?domain=missing-${RUN_ID}.example.com"
+  api v1 GET "/organizations?domain=missing-${RUN_ID}.example.com"
   check "GET unknown domain" 404
 
-  api GET "/organizations?domain=not-a-valid-domain"
+  api v1 GET "/organizations?domain=not-a-valid-domain"
   check "GET invalid domain" 400
 
-  api POST /organizations "$(json \
+  api v1 POST /organizations "$(json \
     --arg name "$ACME_NAME" --arg domain "$ACME_DOMAIN" --arg source "$SOURCE" \
     '{name:$name, domain:$domain, source:$source}')"
   check "POST recreate Acme is idempotent" 201 ".id == \"$ACME_ID\""
 
-  api POST /organizations "$(json \
+  api v1 POST /organizations "$(json \
     --arg name "Bad-${RUN_ID}" --arg source "$SOURCE" \
     '{name:$name, domain:"not-a-valid-domain", source:$source}')"
   check "POST reject invalid domain" 400
@@ -229,7 +229,7 @@ suite_members() {
   local sam_lfid="sam.rivera-${RUN_ID}"
   local sam_id
 
-  api POST /members "$(json \
+  api v1 POST /members "$(json \
     --arg lfid "$sam_lfid" --arg by "$VERIFIED_BY" '{
       displayName: "Sam Rivera",
       identities: [{
@@ -244,19 +244,19 @@ suite_members() {
   check "POST create Sam" 201 'has("memberId")'
   sam_id="$(jq -r '.memberId' <<<"$BODY")"
 
-  api POST /members "$(json '{displayName:"No Identities", identities:[]}')"
+  api v1 POST /members "$(json '{displayName:"No Identities", identities:[]}')"
   check "POST reject empty identities" 400
 
-  api POST /members/resolve "$(json --arg lfid "$PERSON_LFID" '{lfids:[$lfid]}')"
+  api v1 POST /members/resolve "$(json --arg lfid "$PERSON_LFID" '{lfids:[$lfid]}')"
   check "POST resolve Jordan by lfid" 200 ".memberId == \"$PERSON_ID\""
 
-  api POST /members/resolve "$(json --arg lfid "$sam_lfid" '{lfids:[$lfid]}')"
+  api v1 POST /members/resolve "$(json --arg lfid "$sam_lfid" '{lfids:[$lfid]}')"
   check "POST resolve Sam by lfid" 200 ".memberId == \"$sam_id\""
 
-  api POST /members/resolve "$(json --arg lfid "nobody-${RUN_ID}" '{lfids:[$lfid]}')"
+  api v1 POST /members/resolve "$(json --arg lfid "nobody-${RUN_ID}" '{lfids:[$lfid]}')"
   check "POST resolve unknown lfid" 404
 
-  api POST /members/resolve "$(json '{lfids:[]}')"
+  api v1 POST /members/resolve "$(json '{lfids:[]}')"
   check "POST resolve reject empty lfids" 400
 }
 
@@ -269,12 +269,12 @@ suite_member_identities() {
   local email="jordan.lee+${RUN_ID}@example.com"
   local identity_id
 
-  api GET "/members/${PERSON_ID}/identities"
+  api v1 GET "/members/${PERSON_ID}/identities"
   check "GET lists seeded lfid" 200 \
     '.identities | type == "array"' \
     ".identities | map(select(.platform == \"lfid\" and .value == \"$PERSON_LFID\")) | length == 1"
 
-  api POST "/members/${PERSON_ID}/identities" "$(json \
+  api v1 POST "/members/${PERSON_ID}/identities" "$(json \
     --arg email "$email" --arg by "$VERIFIED_BY" '{
       value: $email,
       platform: "email",
@@ -288,7 +288,7 @@ suite_member_identities() {
     '.verified == false'
   identity_id="$(jq -r '.id' <<<"$BODY")"
 
-  api POST "/members/${PERSON_ID}/identities" "$(json \
+  api v1 POST "/members/${PERSON_ID}/identities" "$(json \
     --arg email "$email" '{
       value: $email,
       platform: "email",
@@ -298,19 +298,19 @@ suite_member_identities() {
     }')"
   check "POST same identity is idempotent" 200 ".id == \"$identity_id\""
 
-  api GET "/members/${PERSON_ID}/identities"
+  api v1 GET "/members/${PERSON_ID}/identities"
   check "GET includes email" 200 \
     ".identities | map(select(.id == \"$identity_id\")) | length == 1"
 
-  api PATCH "/members/${PERSON_ID}/identities/${identity_id}" \
+  api v1 PATCH "/members/${PERSON_ID}/identities/${identity_id}" \
     "$(json --arg by "$VERIFIED_BY" '{verified:true, verifiedBy:$by}')"
   check "PATCH verify email" 200 '.verified == true'
 
-  api PATCH "/members/${PERSON_ID}/identities/${identity_id}" \
+  api v1 PATCH "/members/${PERSON_ID}/identities/${identity_id}" \
     "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')"
   check "PATCH reject email deletes unused identity" 204
 
-  api GET "/members/${PERSON_ID}/identities"
+  api v1 GET "/members/${PERSON_ID}/identities"
   check "GET email gone after reject" 200 \
     ".identities | map(select(.id == \"$identity_id\")) | length == 0"
 }
@@ -323,7 +323,7 @@ suite_member_work_experiences() {
 
   local acme_we globex_we doomed_we
 
-  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+  api v1 POST "/members/${PERSON_ID}/work-experiences" "$(json \
     --arg org "$ACME_ID" --arg by "$VERIFIED_BY" '{
       organizationId: $org,
       jobTitle: "Platform Engineer",
@@ -339,7 +339,7 @@ suite_member_work_experiences() {
     ".organizationDomains | index(\"$ACME_DOMAIN\") != null"
   acme_we="$(jq -r '.id' <<<"$BODY")"
 
-  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+  api v1 POST "/members/${PERSON_ID}/work-experiences" "$(json \
     --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
       organizationId: $org,
       jobTitle: "Software Engineer",
@@ -352,7 +352,7 @@ suite_member_work_experiences() {
   check "POST create Globex stint" 201
   globex_we="$(jq -r '.id' <<<"$BODY")"
 
-  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+  api v1 POST "/members/${PERSON_ID}/work-experiences" "$(json \
     --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
       organizationId: $org,
       jobTitle: "ignored title",
@@ -364,7 +364,7 @@ suite_member_work_experiences() {
     }')"
   check "POST overlapping Globex email-domain row" 201
 
-  api GET "/members/${PERSON_ID}/work-experiences"
+  api v1 GET "/members/${PERSON_ID}/work-experiences"
   check "GET groups to two visible rows" 200 \
     '.workExperiences | length == 2' \
     ".workExperiences[] | select(.id == \"$globex_we\") | .jobTitle == \"Software Engineer\"" \
@@ -374,7 +374,7 @@ suite_member_work_experiences() {
     ".workExperiences[] | select(.id == \"$globex_we\") | .endDate | startswith(\"2023-01-01\")" \
     ".workExperiences[] | select(.organizationId == \"$GLOBEX_ID\") | .id == \"$globex_we\""
 
-  api PUT "/members/${PERSON_ID}/work-experiences/${globex_we}" "$(json \
+  api v1 PUT "/members/${PERSON_ID}/work-experiences/${globex_we}" "$(json \
     --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
       organizationId: $org,
       jobTitle: "Senior Software Engineer",
@@ -386,37 +386,37 @@ suite_member_work_experiences() {
     }')"
   check "PUT update Globex title" 200 '.jobTitle == "Senior Software Engineer"'
 
-  api GET "/members/${PERSON_ID}/work-experiences"
+  api v1 GET "/members/${PERSON_ID}/work-experiences"
   check "GET shows updated title" 200 \
     ".workExperiences[] | select(.id == \"$globex_we\") | .jobTitle == \"Senior Software Engineer\""
 
-  api PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \
+  api v1 PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \
     "$(json --arg by "$VERIFIED_BY" '{verified:true, verifiedBy:$by}')"
   check "PATCH verify Globex" 200 \
     '.verified == true' \
     ".id == \"$globex_we\""
 
-  api GET "/members/${PERSON_ID}/work-experiences"
+  api v1 GET "/members/${PERSON_ID}/work-experiences"
   check "GET shows Globex verified" 200 \
     ".workExperiences[] | select(.id == \"$globex_we\") | .verified == true"
 
-  api PATCH "/members/${PERSON_ID}/work-experiences/${acme_we}" \
+  api v1 PATCH "/members/${PERSON_ID}/work-experiences/${acme_we}" \
     "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')"
   check "PATCH reject Acme" 200 '.verified == false'
 
-  api GET "/members/${PERSON_ID}/work-experiences"
+  api v1 GET "/members/${PERSON_ID}/work-experiences"
   check "GET reject hides Acme" 200 \
     '.workExperiences | length == 1' \
     ".workExperiences[0].id == \"$globex_we\""
 
-  api PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \
+  api v1 PATCH "/members/${PERSON_ID}/work-experiences/${globex_we}" \
     "$(json --arg by "$VERIFIED_BY" '{verified:false, verifiedBy:$by}')"
   check "PATCH reject Globex" 200
 
-  api GET "/members/${PERSON_ID}/work-experiences"
+  api v1 GET "/members/${PERSON_ID}/work-experiences"
   check "GET all rejects leave empty list" 200 '.workExperiences | length == 0'
 
-  api POST "/members/${PERSON_ID}/work-experiences" "$(json \
+  api v1 POST "/members/${PERSON_ID}/work-experiences" "$(json \
     --arg org "$ACME_ID" --arg by "$VERIFIED_BY" '{
       organizationId: $org,
       jobTitle: "Contractor",
@@ -429,10 +429,10 @@ suite_member_work_experiences() {
   check "POST create stint for delete" 201
   doomed_we="$(jq -r '.id' <<<"$BODY")"
 
-  api DELETE "/members/${PERSON_ID}/work-experiences/${doomed_we}"
+  api v1 DELETE "/members/${PERSON_ID}/work-experiences/${doomed_we}"
   check "DELETE work experience" 204
 
-  api GET "/members/${PERSON_ID}/work-experiences"
+  api v1 GET "/members/${PERSON_ID}/work-experiences"
   check "GET empty after delete" 200 '.workExperiences | length == 0'
 }
 
@@ -442,7 +442,7 @@ suite_member_maintainer_roles() {
   echo
   echo "=== /members/:id/maintainer-roles ==="
 
-  api GET "/members/${PERSON_ID}/maintainer-roles"
+  api v1 GET "/members/${PERSON_ID}/maintainer-roles"
   check "GET empty roles for fresh member" 200 \
     '.maintainerRoles | type == "array"' \
     '.maintainerRoles | length == 0'
@@ -458,12 +458,12 @@ suite_member_project_affiliations() {
 
   local missing_project="00000000-0000-4000-8000-000000000099"
 
-  api GET "/members/${PERSON_ID}/project-affiliations"
+  api v1 GET "/members/${PERSON_ID}/project-affiliations"
   check "GET empty affiliations for fresh member" 200 \
     '.projectAffiliations | type == "array"' \
     '.projectAffiliations | length == 0'
 
-  api PATCH "/members/${PERSON_ID}/project-affiliations/${missing_project}" \
+  api v1 PATCH "/members/${PERSON_ID}/project-affiliations/${missing_project}" \
     "$(json --arg by "$VERIFIED_BY" --arg org "$ACME_ID" '{
       verifiedBy: $by,
       affiliations: [{
diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
index 3433a6bcd5..33196bccb9 100644
--- a/.github/workflows/api-e2e-tests.yml
+++ b/.github/workflows/api-e2e-tests.yml
@@ -58,7 +58,7 @@ jobs:
           set -euo pipefail
           # Builder only runs kubectl set image; wait for the new revision before health.
           kubectl rollout status deployment/api-e2e-dpl --timeout=600s
-          health_url="${CDP_API_E2E_BASE_URL%/v1}/health"
+          health_url="${CDP_API_E2E_BASE_URL%/}/health"
           for i in $(seq 1 36); do
             curl -fsS --max-time 10 "$health_url" >/dev/null && exit 0
             sleep 10
diff --git a/docs/adr/0008-how-we-write-unit-tests.md b/docs/adr/0008-how-we-write-unit-tests.md
index 3e6f84f2fb..5de6402a73 100644
--- a/docs/adr/0008-how-we-write-unit-tests.md
+++ b/docs/adr/0008-how-we-write-unit-tests.md
@@ -25,6 +25,8 @@ reuse is real.
 - Setup should make that story obvious (concrete labels, dates, relationships).
 - Do not combine unrelated behaviors in a single test.
 - Prefer fewer tests that fail clearly over many that pass for the wrong reason.
+- When a file covers more than one function, group cases with
+  `describe('<functionName>', …)` and put named scenarios inside.
 
 ### Assertions
 
@@ -33,6 +35,18 @@ reuse is real.
 - Avoid loose probes (`some`, `greaterThan`, optional finds) unless the claim
   truly is approximate.
 
+### Mocking
+
+- Do not mock the data-access layer or the unit under test. Postgres-backed
+  tests use a real test database and factories; query behavior is part of what
+  we ship.
+- Reach for mocks only at external network boundaries where the real dependency
+  would hurt reliability or speed (outbound HTTP, LLM clients, Slack/email
+  senders, Auth0 JWKS in end-to-end tests).
+- Control non-determinism in-process when needed (e.g. fake timers for “now”).
+- If a test seems to need a mock outside that list, the unit is often too large:
+  extract the pure part, unit-test it, and let the outer path stay real.
+
 ### What to share
 
 | Layer | Where | Belongs |
diff --git a/docs/adr/0013-api-e2e-test-suite-design.md b/docs/adr/0013-api-e2e-test-suite-design.md
index 3eba457359..31dec4ad5e 100644
--- a/docs/adr/0013-api-e2e-test-suite-design.md
+++ b/docs/adr/0013-api-e2e-test-suite-design.md
@@ -20,7 +20,7 @@ The suite is **thin**: assert the HTTP contract and critical flows so regression
 
 | Layer | Role |
 | --- | --- |
-| Helpers | `api` (call), `check` (soft status + body preds), `require` (hard fail for seed) |
+| Helpers | `api <version> <method> <path> [body]` (call), `check` (soft status + body preds), `require` (hard fail for seed) |
 | Seed | Create shared fixtures once per run over HTTP |
 | Suites | One `suite_*` per **resource path**, not per HTTP method |
 | Cases | One exchange: `api` then `check`. Stateful resources stay ordered as a short story |

From 8c7b84c5da1918b8abe5d85da5dd9758ed5c3882 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:40:02 +0530
Subject: [PATCH 09/12] chore: move testing agent skills to separate PR
 (CM-968)

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .claude/skills/write-api-e2e-tests/SKILL.md | 66 -------------------
 .claude/skills/write-unit-tests/SKILL.md    | 71 ---------------------
 2 files changed, 137 deletions(-)
 delete mode 100644 .claude/skills/write-api-e2e-tests/SKILL.md
 delete mode 100644 .claude/skills/write-unit-tests/SKILL.md

diff --git a/.claude/skills/write-api-e2e-tests/SKILL.md b/.claude/skills/write-api-e2e-tests/SKILL.md
deleted file mode 100644
index 6c3d8c571a..0000000000
--- a/.claude/skills/write-api-e2e-tests/SKILL.md
+++ /dev/null
@@ -1,66 +0,0 @@
----
-name: write-api-e2e-tests
-description: >
-  Write API end-to-end tests. Use when adding or changing API endpoints, or when
-  the user asks for API e2e, smoke, or contract tests.
-allowed-tools: Bash, Read, Glob, Grep, Edit, Write, AskUserQuestion
----
-
-# Write API end-to-end tests
-
-Write or extend API end-to-end tests.
-
-## When to use
-
-- New or changed API endpoints.
-- User asks for API e2e, smoke, or contract tests.
-- Critical API behaviour needs regression coverage.
-
-## When not to use
-
-- Domain or SQL correctness → use `write-unit-tests`.
-- Temporal, OpenSearch, or other eventual side effects outside the documented API e2e scope.
-- Creating a new testing framework or suite style.
-
-## Source of truth
-
-Read before writing. Follow these ADRs and existing suite structure; do not
-invent a parallel testing style.
-
-- [ADR-0012](docs/adr/0012-api-e2e-test-architecture.md) — runtime, isolation, supported surfaces, scope, and assertions.
-- [ADR-0013](docs/adr/0013-api-e2e-test-suite-design.md) — suite organisation, helpers, and conventions.
-
-Current default entrypoint:
-
-- `.github/scripts/public-api-e2e-tests.sh`
-
-## Workflow
-
-1. Identify the API surface. Default to Public API unless the user specifies otherwise.
-2. Read ADR-0012 and ADR-0013.
-3. Add or extend the appropriate suite and register it if required.
-4. Run the affected suite locally and fix failures until green.
-5. If required fixtures cannot be created through the API, prefer testing supported scenarios and explain any coverage gaps instead of seeding the database directly.
-6. Suggest production testability improvements only when they make the API easier to test, and ask before changing production code.
-
-## Run
-
-Export the environment variables required by the suite entrypoint.
-
-```bash
-bash .github/scripts/public-api-e2e-tests.sh
-```
-
-Refer to ADR-0012 and the suite entrypoint for environment setup, reset behaviour,
-and local development workflows.
-
-## Guardrails
-
-- Keep tests focused on observable API behaviour.
-
-## Output
-
-- Suites and cases added
-- How to re-run
-- Coverage gaps, if any
-- Optional testability suggestions
diff --git a/.claude/skills/write-unit-tests/SKILL.md b/.claude/skills/write-unit-tests/SKILL.md
deleted file mode 100644
index c8d3082480..0000000000
--- a/.claude/skills/write-unit-tests/SKILL.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-name: write-unit-tests
-description: >
-  Write focused Vitest unit tests. Use when adding or improving unit tests for
-  business logic, data access, common services, or other server modules.
-allowed-tools: Bash, Read, Glob, Grep, Edit, Write, AskUserQuestion
----
-
-# Write unit tests
-
-Write focused unit tests that follow the project's testing conventions.
-
-## When to use
-
-- User asks to add or improve unit tests.
-- A change touches high-blast-radius logic (affiliations, merges, identity resolution, timelines, inference).
-- A PR needs confidence in a pure or Postgres-backed function.
-
-## When not to use
-
-- Public or HTTP contract coverage → use `write-api-e2e-tests`.
-- Temporal, Redis, or OpenSearch fixtures (not available yet).
-- Broad "increase coverage %" requests without a clear unit under test.
-
-## Source of truth
-
-Read before writing. Follow these ADRs; do not invent a parallel testing style.
-
-- [ADR-0008](docs/adr/0008-how-we-write-unit-tests.md) — scenarios, `describe` grouping, assertions, mocking, and shared setup.
-- [ADR-0007](docs/adr/0007-test-factory-primitives-and-defaults.md) — factories and defaults.
-
-## Workflow
-
-1. Identify the unit under test (one function or decision path). Colocate tests as `<file>.test.ts`.
-2. Read ADR-0007 and ADR-0008. Skim the nearest existing test in the same area if one exists.
-3. Compose fixtures using `@crowd/test-kit` (`withQx` for Postgres-backed tests; factories and opt-in defaults per ADR-0007).
-4. Write focused scenarios following ADR-0008 (grouping, naming, assertions, and mocking).
-5. Run the affected tests and fix failures until green.
-6. If production code is difficult to test, suggest a small testability seam and ask before changing production code.
-
-## Run
-
-Start the test database when needed:
-
-```bash
-./scripts/cli scaffold up-test
-```
-
-Run a focused test file:
-
-```bash
-pnpm test:server -- path/to/file.test.ts
-```
-
-Optional:
-
-```bash
-pnpm test:changed
-pnpm test:watch -- path/to/file.test.ts
-```
-
-## Guardrails
-
-- Prefer critical behaviours over trivial getters, setters, and thin wrappers.
-- Keep production behaviour unchanged unless the user explicitly asks for a testability improvement.
-
-## Output
-
-- Scenarios covered
-- How to re-run
-- Optional testability suggestions

From 174bc0e4659c85cd2a637b2ef9785599e16ff4c1 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:26:54 +0530
Subject: [PATCH 10/12] fix: address remaining PR review comments (CM-968)

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh  |  3 ++-
 docs/adr/0008-how-we-write-unit-tests.md | 14 --------------
 2 files changed, 2 insertions(+), 15 deletions(-)

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index b6b8ca5dc2..77212b919a 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -114,8 +114,9 @@ json() {
 
 fetch_token() {
   local response
+  local issuer="${AUTH0_STAGING_ISSUER%/}"
   response="$(curl -sS --connect-timeout "$CURL_CONNECT_TIMEOUT" --max-time "$CURL_MAX_TIME" \
-    -X POST "${AUTH0_STAGING_ISSUER}/oauth/token" \
+    -X POST "${issuer}/oauth/token" \
     -H 'Content-Type: application/json' \
     -d "$(json \
       --arg client_id "$AUTH0_STAGING_API_E2E_CLIENT_ID" \
diff --git a/docs/adr/0008-how-we-write-unit-tests.md b/docs/adr/0008-how-we-write-unit-tests.md
index 5de6402a73..3e6f84f2fb 100644
--- a/docs/adr/0008-how-we-write-unit-tests.md
+++ b/docs/adr/0008-how-we-write-unit-tests.md
@@ -25,8 +25,6 @@ reuse is real.
 - Setup should make that story obvious (concrete labels, dates, relationships).
 - Do not combine unrelated behaviors in a single test.
 - Prefer fewer tests that fail clearly over many that pass for the wrong reason.
-- When a file covers more than one function, group cases with
-  `describe('<functionName>', …)` and put named scenarios inside.
 
 ### Assertions
 
@@ -35,18 +33,6 @@ reuse is real.
 - Avoid loose probes (`some`, `greaterThan`, optional finds) unless the claim
   truly is approximate.
 
-### Mocking
-
-- Do not mock the data-access layer or the unit under test. Postgres-backed
-  tests use a real test database and factories; query behavior is part of what
-  we ship.
-- Reach for mocks only at external network boundaries where the real dependency
-  would hurt reliability or speed (outbound HTTP, LLM clients, Slack/email
-  senders, Auth0 JWKS in end-to-end tests).
-- Control non-determinism in-process when needed (e.g. fake timers for “now”).
-- If a test seems to need a mock outside that list, the unit is often too large:
-  extract the pure part, unit-test it, and let the outer path stay real.
-
 ### What to share
 
 | Layer | Where | Belongs |

From 085cf0065b9a553739c0fc2af95b19e9686cd88a Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:52:20 +0530
Subject: [PATCH 11/12] fix: align deploy tag with checkout and harden
 body_field (CM-968)

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/scripts/public-api-e2e-tests.sh | 15 ++++++++++-----
 .github/workflows/api-e2e-tests.yml     |  5 +++++
 2 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/.github/scripts/public-api-e2e-tests.sh b/.github/scripts/public-api-e2e-tests.sh
index 77212b919a..5c2a61baf3 100755
--- a/.github/scripts/public-api-e2e-tests.sh
+++ b/.github/scripts/public-api-e2e-tests.sh
@@ -112,6 +112,11 @@ json() {
   jq -n "$@"
 }
 
+# Soft-read a field from response BODY
+body_field() {
+  jq -r --arg k "$1" 'if type == "object" then .[$k] // empty else empty end' <<<"$BODY" 2>/dev/null || true
+}
+
 fetch_token() {
   local response
   local issuer="${AUTH0_STAGING_ISSUER%/}"
@@ -243,7 +248,7 @@ suite_members() {
       }]
     }')"
   check "POST create Sam" 201 'has("memberId")'
-  sam_id="$(jq -r '.memberId' <<<"$BODY")"
+  sam_id="$(body_field memberId)"
 
   api v1 POST /members "$(json '{displayName:"No Identities", identities:[]}')"
   check "POST reject empty identities" 400
@@ -287,7 +292,7 @@ suite_member_identities() {
   check "POST add email identity" 201 \
     ".value == \"$email\"" \
     '.verified == false'
-  identity_id="$(jq -r '.id' <<<"$BODY")"
+  identity_id="$(body_field id)"
 
   api v1 POST "/members/${PERSON_ID}/identities" "$(json \
     --arg email "$email" '{
@@ -338,7 +343,7 @@ suite_member_work_experiences() {
     'has("id") and (has("workExperiences") | not)' \
     ".organizationName == \"$ACME_NAME\"" \
     ".organizationDomains | index(\"$ACME_DOMAIN\") != null"
-  acme_we="$(jq -r '.id' <<<"$BODY")"
+  acme_we="$(body_field id)"
 
   api v1 POST "/members/${PERSON_ID}/work-experiences" "$(json \
     --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
@@ -351,7 +356,7 @@ suite_member_work_experiences() {
       endDate: "2022-06-01T00:00:00.000Z"
     }')"
   check "POST create Globex stint" 201
-  globex_we="$(jq -r '.id' <<<"$BODY")"
+  globex_we="$(body_field id)"
 
   api v1 POST "/members/${PERSON_ID}/work-experiences" "$(json \
     --arg org "$GLOBEX_ID" --arg by "$VERIFIED_BY" '{
@@ -428,7 +433,7 @@ suite_member_work_experiences() {
       endDate: "2025-06-01T00:00:00.000Z"
     }')"
   check "POST create stint for delete" 201
-  doomed_we="$(jq -r '.id' <<<"$BODY")"
+  doomed_we="$(body_field id)"
 
   api v1 DELETE "/members/${PERSON_ID}/work-experiences/${doomed_we}"
   check "DELETE work experience" 204
diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
index 33196bccb9..5b80838539 100644
--- a/.github/workflows/api-e2e-tests.yml
+++ b/.github/workflows/api-e2e-tests.yml
@@ -24,6 +24,10 @@ jobs:
         with:
           ref: main
 
+      - name: Resolve deploy image tag
+        id: deploy
+        run: echo "tag=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+
       - name: Install OCI
         env:
           OCI_CLI_INSTALL_REF: v3.89.3
@@ -52,6 +56,7 @@ jobs:
           ORACLE_CLUSTER: ${{ secrets.ORACLE_STAGING_CLUSTER }}
         with:
           services: api-e2e
+          tag: ${{ steps.deploy.outputs.tag }}
 
       - name: Wait for api-e2e service to be ready
         run: |

From 5954813de7bc5f2e2d0cb39b2fae8afb933770c3 Mon Sep 17 00:00:00 2001
From: Yeganathan S <63534555+skwowet@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:13:02 +0530
Subject: [PATCH 12/12] fix: resolve pr review comments

Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
---
 .github/workflows/api-e2e-tests.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/api-e2e-tests.yml b/.github/workflows/api-e2e-tests.yml
index 5b80838539..5ebf21b6a5 100644
--- a/.github/workflows/api-e2e-tests.yml
+++ b/.github/workflows/api-e2e-tests.yml
@@ -7,7 +7,7 @@ on:
 
 concurrency:
   group: api-e2e-tests
-  cancel-in-progress: false
+  cancel-in-progress: true
 
 permissions:
   contents: read
