# How Application Tracking Works in the AI-Job-Search Framework: CSV-Based Pipeline Explained

> Discover how application tracking works in the AI-Job-Search framework. Learn about the CSV pipeline and immutable source of truth managed by the apply command. Boost your job search efficiency.

- Repository: [Mads Lorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search)
- Tags: internals
- Published: 2026-08-26

---

**Application tracking in the ai-job-search framework centers on a single CSV file `job_search_tracker.csv` that serves as the immutable source of truth, managed exclusively by the `/apply` command through case-insensitive matching logic to update existing open applications or append new records.**

The ai-job-search framework by MadsLorentzen implements a deterministic application tracking system that persists every job search activity to a centralized CSV file. This design ensures that downstream commands such as `/outcome`, `/rank`, and `/interview` read from a consistent, canonical dataset rather than scattered state or external APIs. The entire tracking mechanism is encapsulated in **Step 6b** of the `/apply` command, which handles schema migration, row deduplication, and archival without external dependencies.

## Tracker Architecture and Design Philosophy

### Single Source of Truth

The framework designates `job_search_tracker.csv` as the sole authoritative record for all pipeline activities. According to [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md), no other component modifies this file, guaranteeing that ranking algorithms and status reports always operate on stable, predictable data. The file path itself is hardcoded as a protected string in [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py), preventing accidental deletion or corruption by auxiliary scripts.

### Immutable Row Stability

The CSV is never reordered, rows are never deleted, and the command never touches [`job_scraper/seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/seen_jobs.json). This immutability ensures that downstream analytics can rely on row identifiers remaining constant throughout the job search lifecycle.

## The /apply Command Recording Logic (Step 6b)

The tracking implementation executes a six-phase workflow every time you submit an application. This logic is fully specified in [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md) under the section "Step 6b: Record the Application."

### Header Initialization and Legacy Migration

When `/apply` runs, it first ensures the CSV exists with the canonical 14-column header: `date`, `company`, `sector`, `role`, `role_type`, `channel`, `status`, `contact_person`, `fit_rating`, `notes`, `cv_file`, `cover_letter_file`, `source`, and `deadline`. If the file exists but lacks the trailing `deadline` column (legacy format), the command automatically appends it, leaving historical rows with empty values while ensuring new records include the field.

### Case-Insensitive Row Matching

The system identifies existing applications by performing a case-insensitive match on the combination of `company` and `role` fields. This matching respects the formal status vocabulary defined in [`tests/test_tracker_status_vocab.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_tracker_status_vocab.py), ensuring only recognized states are considered when determining if a row is "open" or final.

### Update Semantics for Open Applications

If a match exists and the current `status` is not final (i.e., not in `{"applied", "offer accepted", "rejected"}`), the command performs an atomic update rather than creating a duplicate. The update refreshes `cv_file`, `cover_letter_file`, `fit_rating`, `source`, and `deadline` while preserving the original `date`. The command appends an undated `redrafted` marker to the `notes` column to maintain an audit trail of modifications.

### Append Logic for New Records

When no open match exists, the command appends a new row with `status` set to **`drafted`**, capturing the current date, the numeric `fit_rating` from Step 1 (0-100), and file paths for generated documents. The `source` field receives the posting URL, or remains empty when the content was pasted manually. The `deadline` field is populated with the exact value extracted from the job posting, or left empty if unavailable.

## CSV Schema and Field Definitions

The tracker uses a strict schema that balances human readability with machine parsing. The column order matches the header exactly:

- **`date`**: ISO format date of activity (today)
- **`company`** and **`role`**: Case-insensitive matching keys
- **`status`**: Controlled vocabulary including `drafted`, `applied`, `offer accepted`, `rejected` (enforced by [`tests/test_tracker_status_vocab.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_tracker_status_vocab.py))
- **`fit_rating`**: Numeric score (0-100) from initial screening
- **`cv_file`** and **`cover_letter_file`**: Paths to generated LaTeX source files
- **`source`**: Original posting URL
- **`deadline`**: Extracted application deadline

## Job Posting Archival

After updating the tracker, the command archives the raw posting text captured in Step 0 to `documents/applications/<company>_<role>/job_posting.md`. This preserves the exact content that was submitted. If the file already exists, it is left untouched, ensuring the archive always reflects the content used for the original submission.

## Practical Implementation Example

The following Python logic mirrors the framework's tracker management as implemented in the source:

```python
import csv
import datetime
import pathlib

TRACKER = pathlib.Path("job_search_tracker.csv")
HEADER = [
    "date","company","sector","role","role_type","channel","status",
    "contact_person","fit_rating","notes","cv_file","cover_letter_file",
    "source","deadline"
]

def ensure_tracker():
    """Initialize CSV with deadline column support for legacy files."""
    if not TRACKER.exists():
        TRACKER.write_text(",".join(HEADER) + "\n")
    else:
        first_line = TRACKER.read_text().splitlines()[0]
        if not first_line.strip().endswith("deadline"):
            new_header = first_line.rstrip("\n") + ",deadline\n"
            rest = "\n".join(TRACKER.read_text().splitlines()[1:]) + "\n"
            TRACKER.write_text(new_header + rest)

def match_row(company, role):
    """Case-insensitive match on company and role."""
    with TRACKER.open(newline="") as f:
        for row in csv.DictReader(f):
            if (row["company"].lower() == company.lower() and
                row["role"].lower() == role.lower()):
                return row
    return None

def write_row(data):
    """Append new record to tracker."""
    with TRACKER.open("a", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=HEADER)
        writer.writerow(data)

def update_row(existing, updates):
    """Rewrite file updating the matched row."""
    rows = []
    with TRACKER.open(newline="") as f:
        for r in csv.DictReader(f):
            if r is existing:
                r.update(updates)
            rows.append(r)

    with TRACKER.open("w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=HEADER)
        writer.writeheader()
        writer.writerows(rows)

# Example usage inside /apply (Step 6b)

ensure_tracker()
row = match_row(company="Acme Corp", role="Data Engineer")
if row and row["status"] not in {"applied", "offer accepted", "rejected"}:
    update_row(row, {
        "date": datetime.date.today().isoformat(),
        "status": "drafted",
        "fit_rating": "85",
        "cv_file": "cv/main_Acme_Corp_Data_Engineer.tex",
        "cover_letter_file": "cover_letters/cover_Acme_Corp_Data_Engineer.tex",
        "notes": row["notes"] + " redrafted"
    })
else:
    write_row({
        "date": datetime.date.today().isoformat(),
        "company": "Acme Corp",
        "sector": "",
        "role": "Data Engineer",
        "role_type": "",
        "channel": "online",
        "status": "drafted",
        "contact_person": "",
        "fit_rating": "85",
        "notes": "",
        "cv_file": "cv/main_Acme_Corp_Data_Engineer.tex",
        "cover_letter_file": "cover_letters/cover_Acme_Corp_Data_Engineer.tex",
        "source": "https://careers.acme.com/job/1234",
        "deadline": "2026-09-30"
    })

```

## Integration with Downstream Commands

The tracker CSV serves as the read-only data source for the `/rank` command (which prioritizes opportunities based on `fit_rating` and `deadline`) and the `/outcome` command (which advances statuses through the pipeline). Because the file is append-only and never reordered, these commands can rely on row stability for consistent analytics and reporting.

## Summary

- The ai-job-search framework uses `job_search_tracker.csv` as the single source of truth for application tracking, protected as a critical asset in [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py)
- The `/apply` command exclusively manages the file through Step 6b, handling both schema migration (adding the `deadline` column to legacy files) and data writes
- Records are matched case-insensitively on `company` and `role`, with updates restricted to rows whose `status` is not final
- The schema includes 14 fields covering metadata, document paths, and extracted deadline information
- Job postings are archived separately to `documents/applications/<company>_<role>/job_posting.md` to preserve submission context
- Unit tests in [`tests/test_apply_records_application.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_apply_records_application.py) validate header conformity and the "tracker-wins" update rules

## Frequently Asked Questions

### What happens if I apply to the same company and role twice?

If the existing application has a final status (`applied`, `offer accepted`, or `rejected`), the second submission appends a new row. Otherwise, the framework updates the existing open row with new document paths and appends a `redrafted` marker to the `notes` column to maintain history.

### Can I manually edit the job_search_tracker.csv file?

While technically possible, manual edits bypass the validation logic in [`tests/test_tracker_status_vocab.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_tracker_status_vocab.py) and may break downstream commands like `/outcome` that expect specific status values. The framework treats this file as a protected asset for this reason.

### How does the framework handle legacy CSV files without a deadline column?

The `/apply` command automatically detects missing headers using the logic in [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md) and appends the `deadline` column to existing files. Legacy rows retain empty values for this field, while new records populate it with extracted deadline data.

### Where is the job posting content stored after I submit an application?

The raw posting text is archived to `documents/applications/<company>_<role>/job_posting.md` immediately after the tracker update, preserving the exact content that was used to generate the application materials. If the file exists, it remains untouched to maintain the historical record.