🚧 Partial implementation; API and web surfaces exist, but upload, email, UX, browser, and runtime proof gaps remain

Spec: F60 — Recruitment / ATS (Internal)

Status: 🚧 Partial implementation; API and web surfaces exist, but upload, email, UX, browser, and runtime proof gaps remain

Current truth (2026-07-16): The former spec-only audit is stale. Recruitment models, public and staff APIs, frontend /jobs and /hq/jobs routes, and focused tests exist. The current implementation does not prove resume upload/storage, F35 confirmation email, all listed UX flows, browser execution, or production behavior. The focused backend suite is commit-bound and passed 30 tests. See the implementation audit and Contract Graph gaps.

Contract Graph boundary. Repository proof assigns these shipped operations but does not establish runner, browser, native, live-service, or production evidence:

  • staff-only POST /api/hq/applications/{id}/notes/ → F60.AC002;
  • staff job-create POST /api/hq/jobs/ → staff_job_list_create_view;
  • public application-create POST /api/jobs/{id}/apply/ → public_job_apply_view.

1. Problem & CEO Job

Job: Trust the signal. (Internal — supports Lumi’s own operations.)

Lumi needs to hire, but its public job posts have no system for creating postings, tracking applicants, or moving candidates through a pipeline; everything is ad-hoc email. This is Lumi’s internal hiring tool, not a tenant feature.

This lightweight ATS runs: staff posting → public /jobs listing → candidate application → HQ review and management.

2. User Stories & Acceptance Criteria

Staff — Manage Job Postings

  • As a Lumi staff member (is_staff=True), I want to create, edit, and close job postings, so that Lumi’s open roles are listed on the website.
    • Given /hq/jobs, staff sees a list of all job postings with status (open/closed/draft), title, department, location, and applicant count. (HqJobsPage.tsx)
    • Given /hq/jobs/new, a form with fields: title, department, location, employment type (full-time/part-time/contract/internship), salary range, description (markdown), requirements (markdown), is_public toggle. (HqNewJobPage.tsx)
    • Given the form is submitted, the job posting is created as draft (not visible publicly) or open (immediately visible on /jobs). (HqNewJobPage.tsx: Save Draft / Publish buttons)
    • Given /hq/jobs/<id>/edit, staff can update any field and change status. (HqNewJobPage.tsx: edit mode via useParams; route added in App.tsx)
    • Given a job with status open, when staff sets status to closed, the posting disappears from /jobs but remains accessible via direct link (with “This position is closed” banner). (JobDetailPage.tsx line 50: closed_banner; JobsListPage filters by open status)

Public — View Job Listings

  • As a candidate, I want to see open positions on the Lumi website, so that I can find roles to apply for.
    • Given /jobs, the page shows all open job postings grouped by department, ordered by most recent first. (JobsListPage.tsx: grouped reduce)
    • Each job card shows: title, department, location, employment type, and a brief excerpt of the description. (JobsListPage.tsx)
    • Given /jobs/<slug>, the detail page shows the full posting with description, requirements, and an “Apply Now” button. (JobDetailPage.tsx)

Candidate — Apply

  • As a candidate, I want to submit my application for a role, so that Lumi’s hiring team can review it.
    • Given /jobs/<slug>/apply, a form with: full name, email, phone (optional), cover letter (textarea), resume/CV upload (PDF, max 10 MB), and optional links (portfolio, LinkedIn, GitHub). (JobApplyPage.tsx)
    • Given the form is submitted with required fields (name, email, resume), the application is saved with status new and the candidate sees a success page. (JobApplyPage.tsx: confirmation_title/confirmation_message shown after submit)
    • Given the form is submitted with a missing required field, the candidate sees a validation error. (JobApplyPage.tsx: button disabled until name + email present; required attributes)
    • Given an existing applicant applies again for the same job, a duplicate check warns the candidate — not yet implemented in backend.
    • A confirmation email is sent to the candidate’s email (via F35) — not yet wired to email backend.

Staff — Review Applications

  • As a staff member, I want to review, filter, and manage applicants, so that I can move candidates through the hiring pipeline.
    • Given /hq/jobs/<id>/applicants, staff sees all applicants for that job in a pipeline view with columns: New, Reviewed, Interview, Offer, Hired, Rejected. (HqJobApplicantsPage.tsx: STAGES array, kanban columns)
    • Each applicant card shows: name, email, stage, days in stage. (HqJobApplicantsPage.tsx lines 131-136)
    • Staff can drag an applicant between stages (PATCH to update stage). (HqJobApplicantsPage.tsx: @hello-pangea/dnd DragDropContext + handleDragEnd → hqJobsApi.updateApplicant)
    • Each column header shows the stage name and applicant count. (HqJobApplicantsPage.tsx: stage name + items.length)
    • Drag-and-drop uses optimistic UI: the card moves immediately on drop, and the PATCH request fires in the background. On failure, the card re-loads. (HqJobApplicantsPage.tsx: setApplicants optimistic → hqJobsApi.updateApplicant → catch calls load())
    • Staff can reject an applicant. (HqJobApplicantsPage.tsx: Reject button → handleStageChange(id, ‘rejected’))
    • Staff can filter the board by applicant name, stage, or date range — not yet implemented.
    • Staff can collapse individual columns — not yet implemented.
    • Given clicking an applicant, a detail panel shows full application (cover letter, resume download, links) — not yet implemented.
    • Staff can add internal notes per applicant — not yet implemented.
    • Staff can download the resume file — not yet implemented.
    • Rejection email template — not yet implemented.

3. Scope Boundaries

  • In:
    • Staff creates/manages job postings in HQ.
    • Public /jobs page listing open roles with detail pages.
    • Candidate application form with resume upload.
    • Confirmation email on application.
    • Staff pipeline view: New → Reviewed → Interview → Offer → Hired → Rejected.
    • Staff notes per applicant.
    • Rejection email template.
    • Job postings area on public homepage (/jobs).
    • Both en + vi localization for public-facing pages.
  • Out:
    • Candidate portal / login (applicants submit once, no account needed).
    • Assessment scoring or skills tests.
    • Interview scheduling (calendar integration).
    • Offer letter generation.
    • Integration with external job boards (LinkedIn, Indeed, etc.).
    • Multi-tenant: this is Lumi-internal only, not a tenant feature.
    • Background check or compliance workflows.

4. Data Model

4.1 JobPosting

Shipped model (backend/apps/recruitment/models.py):

class JobPosting(UUIDModel):
    JOB_STATUS_CHOICES = [
        ("draft", "Draft"),
        ("open", "Open"),
        ("closed", "Closed"),
    ]
    EMPLOYMENT_TYPE_CHOICES = [
        ("full-time", "Full-time"),   # Note: hyphen, not underscore
        ("part-time", "Part-time"),
        ("contract", "Contract"),
        ("internship", "Internship"),
    ]

    title = models.CharField(max_length=200)
    # NO slug field — JobPosting uses UUID pk throughout
    # NO department, salary_range, requirements, is_public fields
    description = models.TextField(blank=True)
    location = models.CharField(max_length=200, blank=True)
    employment_type = models.CharField(max_length=20, choices=EMPLOYMENT_TYPE_CHOICES, default="full-time")
    status = models.CharField(max_length=20, choices=JOB_STATUS_CHOICES, default="draft")
    created_by = models.ForeignKey(User, on_delete=CASCADE, related_name="job_postings")
    # Inherited from UUIDModel: id, created_at, updated_at

    class Meta:
        db_table = "job_postings"
        ordering = ["-created_at"]

Differences from original spec design: no slug (UUID used everywhere), no department/salary_range/requirements/is_public fields, employment type choices use hyphens ("full-time") not underscores.

4.2 JobApplication

Shipped model (backend/apps/recruitment/models.py):

class JobApplication(UUIDModel):
    APPLICATION_STATUS_CHOICES = [   # field is `status`, NOT `stage`
        ("new", "New"),
        ("reviewed", "Reviewed"),
        ("interview", "Interview"),
        ("offer", "Offer"),
        ("hired", "Hired"),
        ("rejected", "Rejected"),
        # NOTE: no "withdrawn" choice in the shipped model
    ]

    job = models.ForeignKey(JobPosting, on_delete=CASCADE, related_name="applications")
    candidate_name = models.CharField(max_length=200)   # NOT full_name
    candidate_email = models.EmailField()               # NOT email
    resume_url = models.URLField(blank=True)            # NOT a FK to Upload
    cover_letter = models.TextField(blank=True)
    status = models.CharField(max_length=20, choices=APPLICATION_STATUS_CHOICES, default="new")
    internal_notes = models.TextField(blank=True)
    # NO phone, portfolio_url, linkedin_url, github_url, previous_application fields
    # Inherited from UUIDModel: id, created_at, updated_at

    class Meta:
        db_table = "job_applications"
        ordering = ["-created_at"]

JobApplicationStaffSerializer fields (what HQ endpoints actually return): id, job, candidate_name, candidate_email, resume_url, cover_letter, status, internal_notes, created_at, updated_at, note_count

Differences from original spec design: field name candidate_name (not full_name), candidate_email (not email), resume_url string field (not Upload FK), status (not stage), only 6 status choices (no withdrawn), no phone/portfolio/linkedin/github/previous_application.

4.3 ApplicationNote (staff-only)

class ApplicationNote(UUIDModel):
    application = models.ForeignKey(JobApplication, on_delete=CASCADE, related_name="notes")
    author = models.ForeignKey(User, on_delete=CASCADE)
    body = models.TextField()

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        db_table = "application_notes"
        ordering = ["-created_at"]

5. API Contract

Shipped routes (backend/apps/recruitment/urls_hq.py, urls.py):

MethodPathAuthDescription
GET/api/jobs/publicList open job postings (status=open).
GET/api/jobs/<uuid>/publicJob detail (UUID pk, no slug). Returns 404 if not open.
POST/api/jobs/<uuid>/apply/publicSubmit application. Rate-limited: 5/hour per IP.
GET/hq/jobs/staffList all job postings with application_count.
POST/hq/jobs/staffCreate job posting.
PATCH/hq/jobs/<uuid>/staffUpdate job posting (status, fields).
GET/hq/jobs/<uuid>/applications/staffList applications for a job (NOT /applicants/).
PATCH/hq/applications/<uuid>/staffUpdate application status/fields (NOT /hq/jobs/applicants/<id>/).
POST/hq/applications/<uuid>/notes/staffAdd internal note to an application.

Contract Graph ownership: the shipped public job-detail route, staff job-list endpoint, and staff application-list endpoint are assigned to public_job_detail_view with a registered backend proof. This records source/test ownership only; browser and production evidence remain separate gaps.

No /reject/ endpoint exists. Reject = PATCH /hq/applications/<uuid>/ with { "status": "rejected" }.

The exact PATCH /api/hq/applications/{id}/ surface is assigned to staff_application_update_view with F60.AC002.APPLICATION_PATCH; repository proof remains separate from backend execution, browser, and production evidence.

Differences from original spec design: paths use /applications/ (not /applicants/); application update path is /hq/applications/<id>/ (not /hq/applicants/<id>/); job detail GET uses UUID not slug; no separate reject endpoint; added notes endpoint.

6. UI / UX

6.1 Public Jobs Page (/jobs)

┌─ Lumi AI Workspace — Careers ──────────────────────────────┐
│                                                              │
│  Join the team building the future of work for SMEs          │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ Engineering                                          │   │
│  │                                                      │   │
│  │ ┌──────────────────────────────────────────────────┐ │   │
│  │ │ Senior Full-Stack Engineer                       │ │   │
│  │ │ Ho Chi Minh City · Full-time · Remote-friendly   │ │   │
│  │ │ Building the CEO brief product. React, Django,   │ │   │
│  │ │ AI/LLM integration.                              │ │   │
│  │ │                                  [View & Apply →] │ │   │
│  │ └──────────────────────────────────────────────────┘ │   │
│  └──────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘

6.2 Job Detail (/jobs/<slug>)

┌─ Senior Full-Stack Engineer ───────────────────────────────┐
│                                                              │
│  🏢 Engineering  📍 HCMC  💼 Full-time  💰 25-40 triệu      │
│                                                              │
│  We're looking for a senior full-stack engineer to join      │
│  our team building Lumi AI Workspace...                      │
│                                                              │
│  Requirements:                                                │
│  • 5+ years experience with Python/Django                    │
│  • 3+ years with React/TypeScript                            │
│  • Experience with AI/LLM integration                        │
│                                                              │
│  [Apply Now]                                                 │
└──────────────────────────────────────────────────────────────┘

6.3 Apply Form (/jobs/<slug>/apply)

Simple form: name, email, phone, cover letter, resume upload, links.

6.4 Staff HQ — Job Management (/hq/jobs)

┌─ HQ › Jobs ────────────────────────────────────────────────┐
│                                                              │
│  [New Job Posting]                                           │
│                                                              │
│  ┌────┬──────────────────────┬──────────┬────────┬─────────┐ │
│  │Status│ Title              │ Dept     │ Apps   │ Updated │ │
│  ├────┼──────────────────────┼──────────┼────────┼─────────┤ │
│  │🟢  │ Senior Full-Stack   │ Eng      │ 12     │ 2d ago  │ │
│  │🔴  │ Product Designer    │ Design   │ 8      │ 5d ago  │ │
│  │⚪  │ Intern — QA         │ Eng      │ —      │ 1h ago  │ │
│  └────┴──────────────────────┴──────────┴────────┴─────────┘ │
└──────────────────────────────────────────────────────────────┘

6.5 Staff HQ — Pipeline View (/hq/jobs/<id>/applicants)

Kanban-style board with horizontal scroll. Six columns: New → Reviewed → Interview → Offer → Hired → Rejected. Each column shows a count badge. An optional filter bar above narrows results. Collapse toggle per column to hide empty or low-priority stages.

┌─ Senior Full-Stack Engineer — Applicants ─────────────────┐
│                                                             │
│  🔍 [Search by name...]  [All Stages ▼]  [Date range ▼]    │
│                                                             │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐        │
│  │ 🆕 New  │ │ 👀 Revie │ │ 🎤 Inter │ │ 💼 Off│        │
│  │   (6)   │ │   (4)    │ │   (2)    │ │  (1)  │        │
│  │ ┌──────┐│ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌────┐│        │
│  │ │Nguyễn││ │ │Trần  │ │ │ │Lê    │ │ │ │Phạm││        │
│  │ │Văn A ││ │ │Thị B │ │ │ │Văn C │ │ │ │Văn ││        │
│  │ │ 2d   ││ │ │ 5d   │ │ │ │ 3d   │ │ │ │ D  ││        │
│  │ └──────┘│ │ └──────┘ │ │ └──────┘ │ │ │ 7d ││        │
│  │ ┌──────┐│ │ ┌──────┐ │ │ ┌──────┐ │ │ └────┘│        │
│  │ │Minh  ││ │ │Hương │ │ │ │Sơn   │ │ │       │        │
│  │ │ 1d   ││ │ │ 2d   │ │ │ │ 1d   │ │ │       │        │
│  │ └──────┘│ │ └──────┘ │ │ └──────┘ │ │       │        │
│  └──────────┘ └──────────┘ └──────────┘ └────────┘        │
│                                                             │
│  ┌──────────┐ ┌──────────┐              [⟳ Collapse All]  │
│  │ ✅ Hired│ │ ❌ Rejec │                                    │
│  │   (1)    │ │   (3)    │                                    │
│  │ ┌──────┐│ │ ┌──────┐ │                                    │
│  │ │Lan   ││ │ │Hoàng │ │                                    │
│  │ │ 10d  ││ │ │ 8d   │ │                                    │
│  │ └──────┘│ │ └──────┘ │                                    │
│  └──────────┘ └──────────┘                                    │
└──────────────────────────────────────────────────────────────┘

**Drag-and-drop behavior:**
- Cards are draggable between all non-terminal columns (New, Reviewed, Interview, Offer).
- Dragging to "Hired" or "Rejected" is allowed from any stage (terminal landing).
- On drop: optimistic move → PATCH `/api/hq/applications/<id>/` with new stage → on success, column counts update; on failure, card snaps back with "Failed to move applicant. Try again." toast.
- Days in stage shown on each card (e.g., "2d") — helpful for spotting stale applicants.
- Staff can click a card to open a slide-in detail panel with full application, resume download, stage history timeline, and notes form.

### 6.6 i18n

Public-facing keys in a new `jobs` namespace:
- `jobs.page_title`, `jobs.page_subtitle`, `jobs.view_apply`
- `jobs.apply_title`, `jobs.apply_name`, `jobs.apply_email`, etc.
- `jobs.confirmation_title`, `jobs.confirmation_message`
- `jobs.closed_banner`
- `jobs.type_full_time`, `jobs.type_part_time`, etc. (all employment types)
- `jobs.department`, `jobs.location`, `jobs.salary`, `jobs.type`

Both `en` and `vi` locale files.

## 7. Test Plan

> **Coverage note (2026-06-14):** The shipped suite is `backend/tests/test_recruitment.py` with classes: `TestJobPostingStaffCRUD`, `TestJobPublicListing`, `TestJobApplicationSubmit`, `TestJobApplicationProgression`, `TestApplicationNote`, `TestApplicationFiltering`, `TestRecruitmentTenantIsolation`, `TestRecruitmentEdgeCases`, `TestPublicJobApplyRateLimit`, `TestPublicJobApplyDuplicate`. Rows below use the original per-case naming; map to these real classes (e.g. public list → `TestJobPublicListing`, apply → `TestJobApplicationSubmit`, stage transition → `TestJobApplicationProgression`, rate limit → `TestPublicJobApplyRateLimit`, duplicate → `TestPublicJobApplyDuplicate`).

| # | Case | Type | Where |
|---|------|------|-------|
| 1 | Public `/api/jobs/` returns only open + public | backend | `tests/test_recruitment.py` |
| 2 | Draft job returns 404 for public, visible to staff | backend | `tests/test_recruitment.py` |
| 3 | Closed job returns 404 for public, accessible via direct link with "closed" banner | backend | `tests/test_recruitment.py` |
| 4 | Submit application creates record with correct stage=new | backend | `tests/test_recruitment.py` |
| 5 | Submit application with missing fields returns 400 | backend | `tests/test_recruitment.py` |
| 6 | Duplicate application (same email + job) creates new row with previous_application FK | backend | `tests/test_recruitment.py` |
| 7 | Staff-only HQ endpoints return 403 for non-staff | backend | `tests/test_recruitment.py` |
| 8 | Stage transitions follow valid path (staff can jump to any stage) | backend | `tests/test_recruitment.py` |
| 9 | Stage transition: reject available from any stage | backend | `tests/test_recruitment.py` |
| 10 | Resume upload validates file type (PDF only) | backend | `tests/test_recruitment.py` |
| 11 | Resume upload validates file size (max 10 MB) | backend | `tests/test_recruitment.py` |
| 12 | Confirmation email sent on successful application (F35 integration) | backend | `tests/test_recruitment.py` |
| 13 | Staff adds internal note on applicant — visible to staff only | backend | `tests/test_recruitment.py` |
| 14 | Public jobs page renders correctly | e2e | `describe("Jobs / Careers")` |
| 15 | Apply flow end-to-end | e2e | same block |
| 16 | Staff pipeline view renders kanban columns with applicant cards | e2e | same block |
| 17 | Rate limiting: >5 applications per IP per hour returns 429 | backend | `tests/test_recruitment.py` |
| 18 | Kanban: drag applicant between stages → PATCH succeeds, column counts update | e2e | `describe("Jobs / Careers")` |
| 19 | Kanban: column header shows correct applicant count | backend | `tests/test_recruitment.py` |
| 20 | Kanban: filter by name filters cards in all columns | e2e | `describe("Jobs / Careers")` |
| 21 | Kanban: collapse column hides that stage from view | frontend | `PipelineBoard.test.tsx` |
| 22 | Kanban: concurrent drag-and-drop — last PATCH wins, loser sees rollback toast | backend | `tests/test_recruitment.py` |
| 23 | /hq/jobs renders heading for staff user | e2e | `cross-cutting-2.spec.ts › F60 — Recruitment ATS` |
| 24 | /hq/jobs/new renders without TypeError for staff user | e2e | `cross-cutting-2.spec.ts › F60 — Recruitment ATS` |
| 25 | Public /jobs page renders without auth | e2e | `cross-cutting-2.spec.ts › F60 — Recruitment ATS` |
| 26 | /api/hq/jobs/ returns list for staff user | e2e/api | `cross-cutting-2.spec.ts › F60 — Recruitment ATS` |
| 27 | Non-staff user redirected away from /hq/jobs | e2e | `cross-cutting-2.spec.ts › F60 — Recruitment ATS` |

## 8. Current Behavior

Shipped. `backend/apps/recruitment/` — `JobPosting`, `JobApplication`, `ApplicationNote` models. Backend views/serializers/urls including public apply + HQ management endpoints. Frontend: public `/jobs` page + `/hq/jobs` staff pipeline. Stage transitions (New → Reviewed → Interview → Offer → Hired + Rejected any-stage). Cross-tenant isolation, rate limiting (5/hour on apply).

## 9. Error States

| # | Scenario | User-visible | Internal | Log | Retry |
|---|----------|-------------|----------|-----|-------|
| 1 | Resume too large (>10 MB) | Field validation: "File exceeds 10 MB limit." | Validation error | INFO "jobs_resume_too_large" | Re-upload smaller file |
| 2 | Resume wrong format (not PDF) | Field validation: "Accepted format: PDF only." | Validation error | INFO "jobs_resume_wrong_format" | Re-upload PDF |
| 3 | Email delivery fails (F35) | No visible change — application saved | Email error, logged | ERROR "jobs_confirmation_email_failed" | Retry once after 5 min |
| 4 | Job not found (public) | 404 | Job lookup fails | WARNING "jobs_not_found" | n/a |
| 5 | Non-staff accesses `/hq/jobs` | 403 redirect to `/work/today` | Permission denied | INFO "jobs_hq_access_denied" | n/a |
| 6 | Invalid stage transition | 400: "Transition from {from} to {to} is not allowed." | Validation error | WARNING "jobs_invalid_stage_transition" | Staff selects valid transition |
| 7 | Apply rate limit exceeded (5/hour) | 429: "Too many applications. Try again later." | Rate limit check | INFO "jobs_rate_limited" | Wait 1 hour |
| 8 | Resume S3 upload fails | Toast: "Resume upload failed. Application saved without resume." | F34 upload error | WARNING "jobs_resume_upload_failed" | Upload in follow-up |
| 9 | Duplicate application within 24h | Confirmation: "You've already applied recently. Submit again?" | Duplicate check | INFO "jobs_duplicate_application" | Candidate confirms |
| 10 | Slug collision on job creation | Slug auto-suffixed: `senior-engineer-2` | Slug dedup | DEBUG "jobs_slug_collision" | n/a |
| 11 | Drag-and-drop PATCH fails (network error) | Toast: "Failed to move applicant. Try again." | Network error | WARNING "jobs_drag_move_failed" | Staff re-drags |
| 12 | Concurrent drag: two staff move same applicant | Toast: "Applicant was moved by another staff member. Board updated." | Last-write-wins conflict | INFO "jobs_concurrent_drag_conflict" | Board refreshes |

## 10. Diagrams & Docs Touched

- `frontend/DIAGRAMS.md` — add `/jobs` public route.
- `backend/DIAGRAMS.md` — add JobPosting + JobApplication models and stage state machine.

## 11. Implementation Audit

Edge Cases Catalog (formerly §9, 2026-06-14; 12 items): job soft-delete to `closed`, duplicate apply with `previous_application` FK, resume upload nullable (app saved without resume), empty pipeline, closed job pipeline still accessible, invalid stage transition logged, rate limiting 5/hour, F35 email failure + retry, staff-handled withdrawal, slug auto-suffix, concurrent drag last-write-wins, rejected→active move allowed. Full catalog in git history.

| # | Claim | Status | Evidence |
|---|-------|--------|----------|
| 1 | `JobPosting`, `JobApplication`, `ApplicationNote` models | ✅ match | `backend/apps/recruitment/models.py` |
| 2 | Backend views, serializers, urls (public apply + HQ management) | ✅ match | `backend/apps/recruitment/` |
| 3 | HQ endpoints (pipeline view, stage transitions, staff actions) | ✅ match | `backend/apps/recruitment/urls_hq.py` |
| 4 | Frontend public `/jobs` page + `/hq/jobs` staff page | ✅ match | Confirmed by registry |
| 5 | Backend tests (stage transitions, cross-tenant, rate limiting) | ✅ match | Registry backend column ✅ |

## 12. Open Questions / Decisions

1. **Public route location.** `/jobs` or `/careers`? Decision: `/jobs`. Simpler, shorter, matches existing conventions. Redirect `/careers` → `/jobs` for SEO.

2. **Resume storage.** Reuse F34 or direct upload? Decision: reuse F34 pre-signed S3 uploads. Consistent, already audited, handles file type/size validation.

3. **Email confirmation.** Use F35 email infrastructure or direct Resend call? Decision: F35. Keeps email config (templates, delivery tracking) in one place.

4. **Application stage transitions.** Linear only or allow skip? Decision: linear recommended (New → Reviewed → Interview → Offer → Hired) but staff can move to any valid stage. Rejected is available from any stage. Withdrawn is candidate-initiated (future).

## 13. Bug / Change Log

### 2026-07-02 — Frontend↔backend realignment: kanban board was fully broken (404/empty)

Type: Bug Fix + Spec Correction

**What was broken:** The HQ applicant kanban board (`/hq/jobs/<id>/applicants`) rendered no cards and silently 404ed on every API call because the frontend was calling three endpoints that do not exist in the shipped backend:

1. `GET /hq/jobs/<id>/applicants/` — **does not exist**; correct path is `/hq/jobs/<id>/applications/`
2. `PATCH /hq/jobs/applicants/<id>/` — **does not exist**; correct path is `/hq/applications/<id>/`
3. `POST /hq/jobs/applicants/<id>/reject/` — **does not exist**; reject = `PATCH /hq/applications/<id>/` with `{ "status": "rejected" }`

Additionally, the frontend `Applicant` interface used field names from the original spec design that differ from the shipped `JobApplicationStaffSerializer`:
- `full_name` → `candidate_name`
- `email` → `candidate_email`
- `stage` → `status`
- `days_in_stage` → removed (serializer has no such field; computed from `updated_at` would be needed but not exposed)

The optimistic-update payload also sent `{ stage: newStatus }` instead of `{ status: newStatus }`.

**Files changed:**
- `frontend/src/api/endpoints.ts` — `hqJobsApi.applicants` path `/applicants/` → `/applications/`; `hqJobsApi.updateApplicant` path `/hq/jobs/applicants/` → `/hq/applications/`; removed non-existent `rejectApplicant` method
- `frontend/src/pages/hq/HqJobApplicantsPage.tsx` — updated `Applicant` interface to real serializer fields; fixed `handleStageChange` payload to `{ status }`, `columnApplicants` filter to `a.status`, card display to `candidate_name`/`candidate_email`; removed `days_in_stage` display
- `frontend/src/test/F60RecruitmentJobs.test.tsx` — updated mock data to real field names; removed `rejectApplicant` from mock
- `docs/specs/done/F60-recruitment-ats.md` — §4 and §5 corrected to match shipped models/endpoints

**Spec corrections made in this PR:** §4.1 JobPosting (no slug, no department/salary/requirements/is_public), §4.2 JobApplication (field names, status not stage, 6 choices not 7, no phone/portfolio/linkedin/github), §5 API table (real paths, no /reject/).

**Verification:** tsc 0 errors, 11,392/11,408 tests pass (16 pre-existing skips), design token guard clean.

### 2026-07-02 — Spec drift audit: no AC changes required

Type: Documentation truth fix

Summary:
- All 8 unchecked ACs reviewed against §11 (5 rows, all ✅). None of the unchecked ACs are confirmed by §11 audit rows — each AC already carries an explicit "not yet implemented" annotation in §2.
- Unimplemented items confirmed: duplicate-check candidate warning (backend not built), confirmation email via F35 (not wired), filter bar (not built), column collapse (not built), applicant detail panel (not built), internal notes UI (not built), resume download (not built), rejection email template (not built).
- No ACs changed. §11 rows 1–5 cover models, backend endpoints, frontend pages, and tests — but not the specific unbuilt UX flows above.

### 2026-06-15 — Frontend route namespaced

Frontend route namespaced: non-staff redirect target `/today` -> `/work/today` (app-wide IA restructure; `/hq` and API paths unchanged).

### 2026-06-14 — Strategy v2 realignment (re-sequenced/deferred)

Type: Documentation Change

Summary:
- Added a "Strategic position (strategy v2, 2026-06-14)" note to §1 marking ATS/recruitment as deferred by doctrine — retained in the codebase but off the active roadmap until the Vietnam vertical is proven.

Reason:
- Align with competitive-strategy-v2.md §7.2 ("Defer / not part of first operating pain") and the registry's Positioning doctrine (v2).

Impact:
- Priority/positioning only. Feature, status, API, data model, and tests are unchanged.

Tests:
- None (documentation-only change).

Notes:
- Do not extend ATS ahead of wedge (F70) or Loop-2 (F71) work.

### 2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)

Type: Documentation Change

Summary:
- §9 Edge Cases (12 items) absorbed into §11 Implementation Audit note.
- §12 Implementation Audit converted from 3-column table to canonical 4-column table (5 rows, all ✅).
- Sections renumbered: §10 ES → §9, §11 Diags → §10, §12 Audit → §11, §13 OQ → §12, §14 BL → §13.

Reason:
- Align with TEMPLATE.md canonical section order (§1–§13) per SP fleet restructuring pass.

Impact:
- Spec authors and future implementation agents.

### 2026-06-13 — Spec refinement: numbered edge cases, detailed error matrix, expanded test plan

Type: Documentation Change

Summary:
- Replaced §9 with 10 numbered edge cases (9.1–9.10) covering: job deletion with active applicants, duplicate applications, resume upload failure, empty jobs, closed jobs with preserved pipeline, invalid stage transitions, rate limiting, confirmation email failure, candidate withdrawal, and slug collision.
- Replaced §10 with 10-row detailed error matrix (user-visible/internal/log/retry), adding coverage for rate limiting, resume upload failure, duplicate confirmation, slug collision, and F34 integration errors.
- Expanded §7 test plan from 9 to 17 test cases, adding coverage for draft visibility, reject-from-any-stage, email confirmation, internal notes, and rate limiting.
- Updated spec status from draft to agreed.

Reason:
- Spec needed hardening per implementation audit. Edge cases were under-specified, test plan was thin, error states lacked structured detail.

Impact:
- Spec authors and future implementation agents.

Tests:
- Spec-only change — no code tests affected.

### 2026-07-16 — Reconciled staff job update API ownership

Type: Contract Graph reconciliation

Summary:
- Confirmed the shipped `PATCH /api/hq/jobs/<uuid>/` route and `staff_job_update_view` implementation.
- Registered the existing staff field/status mutation test as `F60.AC002.JOB_PATCH`.
- Runtime execution remains an explicit backend environment gap; no browser, live-service, or production evidence is inferred.

### 2026-06-14 — Added kanban pipeline UX detail: column counts, drag-drop, filters, concurrent conflict

Type: Documentation Change

Summary:
- Expanded §6.5 with full kanban mockup showing 6 columns, column count badges, filter bar, collapse toggle, and drag-drop behavior (optimistic move, rollback on failure, days-in-stage).
- Added ACs for column counts, filter bar, collapse toggle, and optimistic drag-drop rollback.
- Added §9.11 (concurrent drag-and-drop conflict — last-write-wins).
- Added §9.12 (rejected applicant moved back to active pipeline — staff-initiated revert).
- Added test cases #18–22 covering kanban column counts, name filter, collapse, concurrent drag.
- Added error state rows #11–12 for drag-drop network failure and concurrent conflict.
- Status remains agreed.

Reason:
- Per T4-G round 2 task: add kanban pipeline UX detail (drag-and-drop status change, column counts, filter by status).

Impact:
- Implementation agents must implement kanban board with optimistic drag-drop, column count badges, filter bar, and concurrent-edit conflict handling.

Tests:
- Spec-only change — no code tests affected.