# How Notion Pipeline Synchronization Works: One-Way Read-Only Sync Explained

> Discover how Notion pipeline synchronization works with one-way read-only sync. Keep your Notion database updated while source files remain the single truth.

- Repository: [Mads Lorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search)
- Tags: how-to-guide
- Published: 2026-07-26

---

**The `/notion-sync` command publishes a read-only, one-way view of your job-search pipeline into a Notion database, ensuring the local repository remains the single source of truth with zero write-back to source files.**

The [MadsLorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search) repository implements a robust Notion pipeline synchronization system that mirrors your job application data without compromising data integrity. This one-way read-only sync architecture guarantees that your local [`seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/seen_jobs.json) and `job_search_tracker.csv` files remain authoritative while providing a convenient Notion interface for visualization. Understanding this synchronization mechanism is essential for maintaining data consistency across your job search workflow.

## What Gets Synchronized

The sync process aggregates data from two primary local sources to create a unified view in Notion.

- **Ranked job entries** from [`job_scraper/seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/seen_jobs.json) are filtered by their `rank_score` and synchronized with their associated metadata including company, role, verdict, and status.
- **Application records** from `job_search_tracker.csv` are fully synchronized, with tracker status taking precedence over scraper status when conflicts arise.
- **Stable identification** is maintained through a **`Key`** property calculated as the lower-cased `<company>_<role>` string, ensuring each job maps to exactly one Notion page regardless of subsequent syncs.

The following files comprise the complete data layer:

| File | Role |
|------|------|
| [`job_scraper/seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/seen_jobs.json) | Contains scraped and ranked job entries with `rank_score`, `status`, and dates. |
| `job_search_tracker.csv` | Tracks applications, outcomes, and associated document filenames. |
| [`job_scraper/notion_sync.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/notion_sync.json) | Stores the Notion database ID and last-sync timestamp (git-ignored). |

## The One-Way Read-Only Architecture

The system enforces a strict **read-only, one-way data flow** from repository to Notion, preventing any possibility of circular updates or data corruption.

The command exclusively reads local JSON and CSV files and performs **upsert** operations in Notion. Page bodies are **write-once**—created during initial page generation but never modified during subsequent updates unless explicitly rebuilding with `--rebuild`. Property fields such as status, score, and verdict receive updates, but the original content body remains immutable to preserve any user-added notes within Notion. No synchronization mechanism exists to write data back into [`seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/seen_jobs.json), the tracker CSV, or any other repository file, ensuring the repository maintains absolute authority over the dataset.

## The Six-Step Sync Workflow

The complete synchronization logic is defined in [`.claude/commands/notion-sync.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/notion-sync.md) and executed through the following pipeline:

### Step 0: Parse Arguments

The command evaluates CLI flags to determine the sync scope. Valid options include `--min-score` to set a threshold (default 60), `--all` to bypass score filtering entirely, and `--rebuild` to force regeneration of page bodies for existing entries.

### Step 1: Pre-flight Notion Connection

The system verifies that the Notion MCP tool is present and authenticated. If the connection check fails, the command exits immediately with a diagnostic message before processing any local data.

### Step 2: Build the Sync Set

The command reads [`job_scraper/seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/seen_jobs.json) and `job_search_tracker.csv`, selecting jobs that meet the score threshold and merging tracker rows. When a job exists in both files, the tracker status takes precedence over the scraper status.

### Step 3: Locate or Create the Database

The system reads [`job_scraper/notion_sync.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/notion_sync.json) to retrieve the existing database ID. If no database exists, it creates a new Notion database with the specified schema (Name, Company, Score, Verdict, Status, Key, URL, etc.) and persists the new ID to [`notion_sync.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/notion_sync.json) for future runs.

### Step 4: Upsert Rows with Stable Keys

For each job in the sync set, the system queries Notion for an existing page matching the `Key` property:

- **No match:** Creates a new page with all properties, then proceeds to Step 5 to write the detailed body.
- **Match found:** Updates only the property fields (Score, Verdict, Status) while leaving the existing page body untouched.

### Step 5: Write Detail Pages (New Pages Only)

For newly created pages, the system fetches the job posting via WebFetch, composes a concise digest, and attaches links to local archives. This step executes only during initial page creation to prevent overwriting user annotations.

### Step 6: Report and Persist State

The command prints a summary of created, updated, unchanged, and failed rows. It stores the latest sync timestamp in [`job_scraper/notion_sync.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/notion_sync.json) to track synchronization history.

```python

# Simplified pseudo-code from Step 4 implementation

if not notion_page_exists(key):
    notion.create_page(
        title=f"{role} — {company}",
        properties={
            "Company": company,
            "Score": rank_score,
            "Verdict": verdict,
            "Status": status,
            "Key": key,
            "URL": posting_url,
        }
    )
    notion.update_page_body(page_id, build_body(job))
else:
    notion.update_properties(page_id, {
        "Score": rank_score,
        "Verdict": verdict,
        "Status": status,
    })

```

## Data Integrity Safeguards

The synchronization implements multiple protective mechanisms to ensure reliable, non-destructive operations:

- **Idempotent upserts** on the `Key` property guarantee that re-running the command never creates duplicate pages, even across multiple execution cycles.
- **Write-once bodies** protect user-added notes in Notion from being clobbered during property updates.
- **Zero side-effects on empty sets**: The command aborts before any external API calls if the sync set is empty, preventing unnecessary network requests.
- **Local document privacy**: Only filenames (`cv_file`, `cover_letter_file`) are stored in Notion properties. The actual PDF documents remain in `documents/applications/` and never leave the local machine.

## Running the Sync Command

Execute the synchronization within the Claude Code CLI using the following patterns:

```text
/notion-sync                # Sync ranked jobs with score ≥ 60 and all tracked applications

/notion-sync --all         # Sync every ranked job regardless of score

/notion-sync --min-score 80   # Only jobs scoring 80 or higher

/notion-sync --rebuild      # Rewrite page bodies for newly created pages

```

After execution, the system updates [`job_scraper/notion_sync.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/notion_sync.json) with connection metadata:

```json
{
  "database_id": "12345678-abcd-ef01-2345-6789abcdef01",
  "database_url": "https://www.notion.so/Job-Search-Pipeline-12345678abcdef01",
  "last_sync": "2026-07-26"
}

```

## Summary

- **One-way read-only sync** ensures the repository remains the single source of truth while providing a Notion visualization layer.
- **Stable Key identification** using lowercase `<company>_<role>` enables idempotent upserts without duplicate creation.
- **Write-once page bodies** preserve user annotations in Notion, with property-only updates on subsequent syncs.
- **Local file authority** means [`job_scraper/seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/seen_jobs.json) and `job_search_tracker.csv` drive all changes, with [`job_scraper/notion_sync.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/job_scraper/notion_sync.json) maintaining only the database pointer and timestamp.
- **Privacy-preserving design** keeps actual documents local, sharing only filenames and metadata with Notion.

## Frequently Asked Questions

### Can I edit data in Notion and have it sync back to the repository?

No. The architecture is strictly one-way. The `/notion-sync` command reads from local files and writes to Notion, but no mechanism exists to propagate changes from Notion back to [`seen_jobs.json`](https://github.com/MadsLorentzen/ai-job-search/blob/main/seen_jobs.json) or `job_search_tracker.csv`. The repository remains the immutable source of truth.

### What happens if I run the sync command multiple times?

The sync is idempotent. Each run queries existing Notion pages by their `Key` property and updates only the property fields (Score, Status, Verdict) for matches. Page bodies remain untouched unless you specify the `--rebuild` flag, ensuring repeated executions never duplicate entries or destroy manual notes.

### How does the system prevent duplicate job entries?

Each job is identified by a stable **`Key`** calculated as the lower-cased `<company>_<role>` string. Before creating any page, the system queries Notion for an existing page with that Key. If found, it updates the existing page; if not found, it creates a new one. This upsert pattern guarantees one page per job regardless of how many times you run the command.

### Are my resume and cover letter files uploaded to Notion?

No. The synchronization stores only the filenames (`cv_file`, `cover_letter_file`) in Notion database properties. The actual PDF documents remain in your local `documents/applications/` directory and are never transmitted to Notion's servers, ensuring your documents stay private and local.