# How set-status.mjs Enforces Canonical State Transitions with Atomic Writes and Locking in santifer/career-ops

> Discover how set-status.mjs in santifer/career-ops ensures canonical state transitions using atomic writes and filesystem locking for robust application status updates.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: internals
- Published: 2026-08-20

---

**`set-status.mjs` acts as the single authoritative CLI that updates application statuses through a three-layer defense: canonical state validation against [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/templates/states.yml), exclusive filesystem locking during the read-modify-write cycle, and atomic file replacement to prevent corruption.**

The `set-status.mjs` script in the santifer/career-ops repository is the only entry point permitted to modify [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md). Its design eliminates an entire class of race condition bugs and data integrity failures through tightly coupled validation, locking, and atomic write mechanisms. This article breaks down exactly how each layer works, with direct references to the source code implementation.

---

## Canonical State Validation: Rejecting Invalid States Before Touching the Tracker

Before any file operation occurs, `set-status.mjs` validates that the requested state exists in the **single source of truth**.

The script imports canonical states from [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/templates/states.yml) at the module level:

```javascript
// set-status.mjs
const STATES_FILE = 'templates/states.yml';

```

At runtime, it calls `loadCanonicalStates()` and `resolveCanonicalState()` from `tracker-utils.mjs` to resolve user input against the canonical list:

```javascript
// set-status.mjs lines 49-56
import { loadCanonicalStates, resolveCanonicalState } from './tracker-utils.mjs';

const states = await loadCanonicalStates(STATES_FILE);
const canonical = resolveCanonicalState(stateInput, states);
if (!canonical) {
  console.error(`"${stateInput}" is not a valid state. Valid states: ${Object.keys(states).join(', ')}`);
  process.exit(1);  // USAGE exit code
}

```

This validation in `tracker-utils.mjs` (lines 43-53, 66-71) aborts with exit code 1 if the state doesn't match. The tracker file remains untouched—no lock is acquired, no write is attempted.

---

## Exclusive Lock Acquisition: Preventing Concurrent Write Corruption

Once validation passes, `set-status.mjs` obtains an **exclusive filesystem lock** before reading the tracker. This lock is shared across all writer scripts in the repository, including `merge-tracker.mjs` and `mark-pdf-ready.mjs`.

The lock acquisition happens here:

```javascript
// set-status.mjs lines 68-71
import { acquireTrackerLockForCli } from './tracker-utils.mjs';

const releaseLock = await acquireTrackerLockForCli(APPS_FILE, { timeout: LOCK_TIMEOUT_MS });

```

The `acquireTrackerLockForCli()` function in `tracker-utils.mjs` (lines 54-58, 84-90) delegates to `acquireTrackerLock()`, which creates a lock directory based on a hash of the tracker path (lines 99-102). If the lock cannot be obtained within the configured timeout, the script exits with code 4 (`LOCK_TIMEOUT`).

The lock is held for the entire **read-modify-write sequence**. No other process can acquire it until the current operation completes.

---

## Atomic File Replacement: Guaranteeing All-or-Nothing Writes

After modifying the tracker data in memory, `set-status.mjs` persists changes through `writeFileAtomic()`:

```javascript
// set-status.mjs lines 26-30 (conceptual wrapping of the atomic write)
await writeFileAtomic(APPS_FILE, updatedContent);

```

The `writeFileAtomic()` implementation in `tracker-utils.mjs` (lines 22-33) performs:

1. **Write to temporary file**: Creates a hidden temp file in the same directory as the target
2. **Rename operation**: Uses `fs.rename()` to atomically replace the original
3. **Windows retry logic**: Retries on contention for Windows compatibility
4. **Cleanup on failure**: Removes the temp file if any step fails

This pattern ensures that readers of [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) never observe a partially written table. The file is either fully updated or unchanged.

---

## The Complete Read-Modify-Write Flow

Putting the three layers together, `set-status.mjs` executes this sequence:

1. **Parse and resolve selectors** — handles `--row`, `--report`, company names, or bare numbers with ambiguity detection (lines 90-112)
2. **Validate canonical state** — aborts early on invalid input
3. **Acquire exclusive lock** — blocks concurrent writers
4. **Read tracker** — `readFileSync` while lock held
5. **Rebuild target row** — updates Status cell, optionally appends Notes with idempotent handling (lines 94-105)
6. **Atomic write** — `writeFileAtomic()` commits changes
7. **Append transition ledger** — adds entry to `status-log.tsv` within the same lock window (lines 37-55)
8. **Release lock** — cleanup happens automatically

The ledger append is critical: because it occurs **before** lock release, the status history cannot interleave with other writers' operations.

---

## Usage Examples

### Basic status update with note

```bash
node set-status.mjs 42 Applied --note "Sent CV"

```

This validates "Applied" against [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/templates/states.yml), acquires the lock, updates row 42's Status cell, appends the note (`; Sent CV`), and writes atomically.

### Explicit selector disambiguation

```bash
node set-status.mjs --row 42 Applied      # tracker row #42

node set-status.mjs --report 123 Applied  # row linking report #123

```

Use these flags when numeric selectors could match multiple contexts.

### Dry-run for preview

```bash
node set-status.mjs 42 Applied --dry-run --json

```

Resolves the selector, validates the state, but skips lock acquisition and file operations. Outputs JSON showing the proposed change.

### Force override for mismatched selectors

```bash
node set-status.mjs 42 Applied --force

```

Bypasses the report-number mismatch guard. Use with caution.

---

## Key Source Files and Functions

| File | Purpose | Key Functions |
|------|---------|---------------|
| `set-status.mjs` | CLI entry point for status updates | Main orchestration, selector resolution, row rebuilding |
| `tracker-utils.mjs` | Shared utilities for all writers | `loadCanonicalStates()`, `resolveCanonicalState()`, `acquireTrackerLockForCli()`, `writeFileAtomic()` |
| [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/templates/states.yml) | Canonical state definitions | YAML source parsed by `loadCanonicalStates()` |

All writer scripts in the career-ops repository share `tracker-utils.mjs`, ensuring consistent locking and atomic write behavior across the entire codebase.

---

## Summary

- **Canonical validation** in `tracker-utils.mjs` rejects invalid states before any file operations
- **Exclusive filesystem locking** through `acquireTrackerLockForCli()` prevents race conditions during read-modify-write cycles
- **Atomic file replacement** via `writeFileAtomic()` guarantees readers never see partial updates
- **Shared lock implementation** across all writer scripts ensures system-wide consistency
- **Ledger append within lock window** maintains serialized status history without interleaving

Together, these mechanisms make `set-status.mjs` a robust, corruption-resistant tool for maintaining the application tracker.

---

## Frequently Asked Questions

### What happens if two `set-status.mjs` commands run simultaneously?

The second command blocks until the first releases the lock or times out. If the timeout expires, the second command exits with code 4 (`LOCK_TIMEOUT`). The lock implementation in `tracker-utils.mjs` uses a directory-based mechanism keyed to the tracker file path hash, ensuring exclusive access across all writer scripts.

### Why does the script use atomic writes instead of just locking?

Locking prevents concurrent modifications but doesn't protect against **crashes during write**. The atomic replace pattern in `writeFileAtomic()` ensures that [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md) is either fully updated with the new content or remains exactly as it was—no truncation, no partial table rows, no corruption from a mid-write process termination.

### Where are valid states defined and how are aliases resolved?

Valid states live in [`templates/states.yml`](https://github.com/santifer/career-ops/blob/main/templates/states.yml). The `loadCanonicalStates()` function parses this file, and `resolveCanonicalState()` matches user input against both canonical names and defined aliases. This centralized definition prevents drift: every writer script enforces the same state vocabulary without hardcoded validation logic.