# Corpus Authoring Plan

> **For agentic workers:** Task 1 builds the validator. Tasks 2+ author one problem each and may run in parallel — one agent per problem, each owning only its own `corpus/<slug>/` directory.

**Goal:** 20 fully-authored problems, each gradeable by all eight phases, validated mechanically so no defect reaches the user.

**Why this is the critical path:** ~140 authored artifacts. Code is finite and mostly done; content is the work.

**Binding specs:**
- `docs/specs/2026-08-18-deterministic-contract.md` — **read this first.** Contract-first design is non-negotiable
- `docs/specs/2026-08-18-standard-and-syllabus.md` — the 34 syllabus items every problem must tag
- `docs/plans/2026-08-18-java-harness.md` — the test layout and default-package convention

**Available and verified:** `.toolchain/jdk-21` (`javac 21.0.12`), `junit-platform-console-standalone.jar`, `harness/DojoConcurrency.java`, `server/lib/{junit,diff,stress,java,corpus}.mjs`.

---

## Conventions

**No git.** End each task with `node tools/snapshot.mjs "<label>"`. The corpus is the irreplaceable part, so snapshot often.
**Tests:** bare `node --test`. Never pass a directory.
**Java:** default package, no `package` declarations, JDK 21 language level.

## The per-problem deliverable

```
corpus/<slug>/
  problem.json                  metadata, prompt, must_haves, skeleton_spec, syllabus tags
  acceptance.json               the VISIBLE checklist — one entry per base test
  contract/                     GIVEN to the candidate, read-only
    <Slug>Api.java              the interface the harness calls
    Entry.java                  the one line he edits to bind his class
    <boundary records/enums>    only types appearing in Api signatures
  scaffold/                     his starting files (often just a stub impl)
  tests_base/
    <Slug>BaseTest.java         hidden; binds ONLY to contract/
  curveballs/
    01-<kebab>/
      REQUIREMENT-CHANGE.md     the text delivered mid-round
      contract-delta/           any ADDED interface methods (optional)
      tests/<Slug>Curveball01Test.java
      budget.json               {"diff_budget": N, "reference_diff": M, "measured": true}
  tests_concurrency/            only where the problem is tagged with E-items
    <Slug>StressTest.java
  reference/
    src/*.java                  a solution that passes every suite above
    DECISION_LOG.md             why each seam exists — the Phase 5 answer key
```

## The five hard rules

Violating any of these produces an invalid measurement, which is worse than no measurement.

1. **Tests reference only contract types.** Never a candidate class, never a field. This is what makes
   naming irrelevant to a deterministic score.
2. **The reference solution must pass every suite in its own problem.** If it cannot, the test is
   wrong. This is a build gate, not a suggestion.
3. **`acceptance.json` mirrors the base suite exactly** — one entry per `@Test`, same wording as its
   `@DisplayName`. The candidate sees *what* is required; only the assertions are hidden.
4. **Diff budgets are measured, never guessed.** Procedure below.
5. **Every problem tags syllabus items**, and collectively the 20 must cover all 34.

## Measuring a diff budget

Never invent the number.

1. Author `reference/src/` so the base suite is green
2. Copy it aside; apply the curveball's requirement **minimally and well** — using the seams, not hacking
3. Confirm the curveball suite is green
4. `reference_diff = measureChange(original, modified).diffLines`
5. **`diff_budget = max(3, d + min(ceil(d / 2), 6))`** where `d` is the measured `reference_diff`
6. Record both numbers with `"measured": true`

**Slack is capped at 6 lines, not scaled.** The original formula was `d + ceil(d/2)`, which is fine for
small `d` and wrong for large: a curveball measured at 2 lines got 1 line of headroom while one measured
at 47 got 24. At 24 lines of headroom the measurement stops discriminating — a clean absorption and a
half-class rewrite both land inside budget and both score level 2, which is the one thing Phase 4 exists
to tell apart. Six lines absorbs a reasonable difference in taste (a helper extracted here rather than
there) without hiding a rewrite.

`tools/rebudget.mjs` re-derives every budget from its recorded `reference_diff`. It never re-measures —
those numbers came from `measureChange` against the real reference and are reproducible from each
`reference-patch/`. Run it after changing the policy, never to massage a number.

A well-seamed reference usually yields `d` of 0–4, so most budgets land at 3–6. **Corpus median is 4,
and 37 of 45 curveballs sit at 10 or below.**

**If `d` exceeds 20, stop and ask which of two things is true**, and write the answer in `PATCH.md`:

- *The reference has the wrong seams* — the common case. Fix the reference, not the budget. Splitwise's
  multi-currency curveball first measured 30, so its author extracted a validation helper into the base
  reference, re-verified the base suite green, and it dropped to 15. That is the expected response.
- *The requirement genuinely adds a new dimension rather than a new value within an existing one* — rare
  but real. `middleware-router`'s method-aware routing adds a second axis to the match key, so even a
  well-seamed router needs a field, a filter and an overload. A capped budget keeps that fair and still
  discriminating, so the curveball may stay. Say so explicitly rather than leaving the number unexplained.

---

### Task 1: The corpus validator

Built before any authoring, so every problem is checked the moment it lands.

**Files:**
- Create: `server/lib/validate-corpus.mjs`
- Create: `server/test/validate-corpus.test.mjs`

- [ ] **Step 1: Write the failing test**

Create `server/test/validate-corpus.test.mjs`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  referencedTypes, findFieldAccess, checkTestBinding,
  checkAcceptanceMatch, displayNames, checkSyllabus,
} from '../lib/validate-corpus.mjs';

test('referencedTypes finds project types and ignores java.* and locals', () => {
  const src = `
    import java.time.Instant;
    class ParkingLotBaseTest {
      @Test void t() {
        ParkingLotApi api = Entry.create();
        Ticket ticket = api.park(new Vehicle("KA01", VehicleType.CAR), Instant.now());
        assertEquals(3, api.availableSpots(VehicleType.TRUCK));
      }
    }`;
  const types = referencedTypes(src);
  assert.ok(types.has('ParkingLotApi'));
  assert.ok(types.has('Entry'));
  assert.ok(types.has('Ticket'));
  assert.ok(types.has('Vehicle'));
  assert.ok(types.has('VehicleType'));
  assert.ok(!types.has('Instant'), 'java.* imports are not project types');
  assert.ok(!types.has('ParkingLotBaseTest'), 'the test class itself is not a reference');
});

test('checkTestBinding passes when every referenced type is in the contract', () => {
  const src = 'class T { void t(){ ParkingLotApi a = Entry.create(); a.park(null, null); } }';
  const r = checkTestBinding(src, new Set(['ParkingLotApi', 'Entry', 'Ticket']));
  assert.deepEqual(r.violations, []);
  assert.equal(r.ok, true);
});

test('checkTestBinding FAILS when a test references a candidate class', () => {
  const src = 'class T { void t(){ ParkingLot lot = new ParkingLot(); lot.park(null); } }';
  const r = checkTestBinding(src, new Set(['ParkingLotApi', 'Entry']));
  assert.equal(r.ok, false);
  assert.ok(r.violations.includes('ParkingLot'),
    'referencing the candidate implementation is the exact defect this rule exists to catch');
});

test('findFieldAccess flags a bare field read on a contract object', () => {
  assert.deepEqual(findFieldAccess('int n = ticket.spotId;'), ['ticket.spotId']);
});

test('findFieldAccess allows method calls and chained calls', () => {
  assert.deepEqual(findFieldAccess('String s = ticket.spotId(); api.park(v, t).ticketId();'), []);
});

test('displayNames extracts every @DisplayName string', () => {
  const src = `
    @Test @DisplayName("parks a car and issues a ticket") void a() {}
    @Test
    @DisplayName("rejects entry when full")
    void b() {}`;
  assert.deepEqual(displayNames(src), ['parks a car and issues a ticket', 'rejects entry when full']);
});

test('checkAcceptanceMatch requires an exact two-way match', () => {
  const names = ['parks a car', 'rejects when full'];
  assert.equal(checkAcceptanceMatch(names, ['parks a car', 'rejects when full']).ok, true);
  const missing = checkAcceptanceMatch(names, ['parks a car']);
  assert.equal(missing.ok, false);
  assert.ok(missing.missingFromAcceptance.includes('rejects when full'));
  const extra = checkAcceptanceMatch(names, ['parks a car', 'rejects when full', 'invented']);
  assert.equal(extra.ok, false);
  assert.ok(extra.missingFromSuite.includes('invented'));
});

test('checkSyllabus reports uncovered items and unknown ids', () => {
  const all = ['A1', 'A2', 'B1'];
  const ok = checkSyllabus(all, { 'parking-lot': ['A1', 'A2'], elevator: ['B1'] });
  assert.equal(ok.ok, true);

  const gap = checkSyllabus(all, { 'parking-lot': ['A1', 'A2'] });
  assert.equal(gap.ok, false);
  assert.deepEqual(gap.uncovered, ['B1']);

  const bogus = checkSyllabus(all, { 'parking-lot': ['A1', 'A2', 'B1', 'Z9'] });
  assert.equal(bogus.ok, false);
  assert.deepEqual(bogus.unknown, [{ problem: 'parking-lot', item: 'Z9' }]);
});
```

- [ ] **Step 2: Run it and confirm failure**

Run: `node --test server/test/validate-corpus.test.mjs`
Expected: FAIL — `Cannot find module '../lib/validate-corpus.mjs'`

- [ ] **Step 3: Implement `server/lib/validate-corpus.mjs`**

```javascript
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, basename } from 'node:path';

/** Java keywords and annotations that look like types but are not. */
const NOT_TYPES = new Set([
  'Test', 'DisplayName', 'BeforeEach', 'AfterEach', 'Nested', 'Override',
  'Assertions', 'String', 'Integer', 'Long', 'Boolean', 'Double', 'Object',
  'Math', 'System', 'List', 'Map', 'Set', 'Optional', 'Exception',
  'RuntimeException', 'IllegalArgumentException', 'IllegalStateException',
  'NullPointerException', 'DojoConcurrency', 'AssertionError', 'Thread',
]);

function stripNoise(src) {
  return src
    .replace(/\/\*[\s\S]*?\*\//g, ' ')
    .replace(/\/\/[^\n]*/g, ' ')
    .replace(/"(?:\\.|[^"\\])*"/g, '""');
}

/** Types imported from java.* are library types, not project types. */
function importedSimpleNames(src) {
  const out = new Set();
  for (const m of src.matchAll(/^\s*import\s+(?:static\s+)?([\w.]+)\s*;/gm)) {
    out.add(m[1].split('.').pop());
  }
  return out;
}

export function referencedTypes(source) {
  const src = stripNoise(source);
  const imported = importedSimpleNames(source);
  const declared = new Set();
  for (const m of src.matchAll(/\b(?:class|interface|enum|record)\s+(\w+)/g)) declared.add(m[1]);

  const types = new Set();
  for (const m of src.matchAll(/\b([A-Z][A-Za-z0-9_]*)\b/g)) {
    const name = m[1];
    if (NOT_TYPES.has(name) || imported.has(name) || declared.has(name)) continue;
    if (/^[A-Z0-9_]+$/.test(name)) continue;      // SCREAMING_CASE constants, not types
    types.add(name);
  }
  return types;
}

export function checkTestBinding(source, contractTypes) {
  const violations = [...referencedTypes(source)].filter((t) => !contractTypes.has(t)).sort();
  return { ok: violations.length === 0, violations };
}

/**
 * Flags `obj.field` reads. Records expose accessors, so any bare field access in a
 * test is either reaching into the candidate's internals or a typo.
 */
export function findFieldAccess(source) {
  const src = stripNoise(source);
  const hits = [];
  for (const m of src.matchAll(/\b([a-z]\w*)\.([a-z]\w*)\b(?!\s*\()/g)) {
    hits.push(`${m[1]}.${m[2]}`);
  }
  return hits;
}

export function displayNames(source) {
  return [...stripNoise(source).matchAll(/@DisplayName\s*\(\s*""\s*\)/g)].length === 0
    ? [...source.matchAll(/@DisplayName\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)/g)].map((m) => m[1])
    : [...source.matchAll(/@DisplayName\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)/g)].map((m) => m[1]);
}

export function checkAcceptanceMatch(suiteNames, acceptanceNames) {
  const suite = new Set(suiteNames);
  const acc = new Set(acceptanceNames);
  const missingFromAcceptance = suiteNames.filter((n) => !acc.has(n));
  const missingFromSuite = acceptanceNames.filter((n) => !suite.has(n));
  return {
    ok: missingFromAcceptance.length === 0 && missingFromSuite.length === 0,
    missingFromAcceptance, missingFromSuite,
  };
}

export function checkSyllabus(allItemIds, taggingByProblem) {
  const known = new Set(allItemIds);
  const covered = new Set();
  const unknown = [];
  for (const [problem, items] of Object.entries(taggingByProblem)) {
    for (const item of items) {
      if (known.has(item)) covered.add(item);
      else unknown.push({ problem, item });
    }
  }
  const uncovered = allItemIds.filter((i) => !covered.has(i));
  return { ok: uncovered.length === 0 && unknown.length === 0, uncovered, unknown };
}

function javaFilesIn(dir) {
  if (!existsSync(dir)) return [];
  const out = [];
  const walk = (d) => {
    for (const e of readdirSync(d)) {
      const full = join(d, e);
      if (statSync(full).isDirectory()) walk(full);
      else if (e.endsWith('.java')) out.push(full);
    }
  };
  walk(dir);
  return out;
}

/** Validate one problem directory. Returns a list of human-readable problems found. */
export function validateProblemDir(dir) {
  const errors = [];
  const slug = basename(dir);
  const need = (p, what) => { if (!existsSync(join(dir, p))) errors.push(`${slug}: missing ${what} (${p})`); };

  need('problem.json', 'metadata');
  need('acceptance.json', 'visible acceptance checklist');
  need('contract', 'contract directory');
  need('tests_base', 'base suite');
  need('reference/src', 'reference solution');
  need('reference/DECISION_LOG.md', 'decision log');
  if (errors.length) return errors;

  const contractFiles = javaFilesIn(join(dir, 'contract'));
  const contractTypes = new Set();
  for (const f of contractFiles) {
    for (const m of readFileSync(f, 'utf8').matchAll(/\b(?:class|interface|enum|record)\s+(\w+)/g)) {
      contractTypes.add(m[1]);
    }
  }

  const entry = contractFiles.find((f) => basename(f) === 'Entry.java');
  if (!entry) errors.push(`${slug}: contract/Entry.java is required — it is the only binding point`);
  else if (!/static\s+\w*Api\s+create\s*\(/.test(readFileSync(entry, 'utf8'))) {
    errors.push(`${slug}: Entry.create() must return the problem's *Api type`);
  }

  const deltaTypes = new Set(contractTypes);
  for (const cb of existsSync(join(dir, 'curveballs')) ? readdirSync(join(dir, 'curveballs')) : []) {
    for (const f of javaFilesIn(join(dir, 'curveballs', cb, 'contract-delta'))) {
      for (const m of readFileSync(f, 'utf8').matchAll(/\b(?:class|interface|enum|record)\s+(\w+)/g)) {
        deltaTypes.add(m[1]);
      }
    }
    const budgetPath = join(dir, 'curveballs', cb, 'budget.json');
    if (!existsSync(budgetPath)) errors.push(`${slug}/${cb}: missing budget.json`);
    else {
      const b = JSON.parse(readFileSync(budgetPath, 'utf8'));
      if (b.measured !== true) errors.push(`${slug}/${cb}: budget must be measured, not guessed`);
      if (!(b.diff_budget >= 3)) errors.push(`${slug}/${cb}: diff_budget must be at least 3`);
    }
    for (const f of javaFilesIn(join(dir, 'curveballs', cb, 'tests'))) {
      const src = readFileSync(f, 'utf8');
      const bind = checkTestBinding(src, deltaTypes);
      if (!bind.ok) errors.push(`${slug}/${cb}/${basename(f)}: references non-contract types ${bind.violations.join(', ')}`);
      const fields = findFieldAccess(src);
      if (fields.length) errors.push(`${slug}/${cb}/${basename(f)}: reads fields ${fields.join(', ')}`);
    }
  }

  const baseFiles = javaFilesIn(join(dir, 'tests_base'));
  if (baseFiles.length === 0) errors.push(`${slug}: tests_base contains no .java files`);

  const suiteNames = [];
  for (const f of baseFiles) {
    const src = readFileSync(f, 'utf8');
    suiteNames.push(...displayNames(src));

    const bind = checkTestBinding(src, contractTypes);
    if (!bind.ok) errors.push(`${slug}/${basename(f)}: references non-contract types ${bind.violations.join(', ')}`);

    const fields = findFieldAccess(src);
    if (fields.length) errors.push(`${slug}/${basename(f)}: reads fields ${fields.join(', ')} — tests must use accessors only`);
  }

  for (const f of javaFilesIn(join(dir, 'tests_concurrency'))) {
    const bind = checkTestBinding(readFileSync(f, 'utf8'), contractTypes);
    if (!bind.ok) errors.push(`${slug}/${basename(f)}: references non-contract types ${bind.violations.join(', ')}`);
  }

  const acceptance = JSON.parse(readFileSync(join(dir, 'acceptance.json'), 'utf8'));
  const match = checkAcceptanceMatch(suiteNames, acceptance.checks ?? []);
  if (!match.ok) {
    if (match.missingFromAcceptance.length) {
      errors.push(`${slug}: acceptance.json is missing ${match.missingFromAcceptance.join(' | ')}`);
    }
    if (match.missingFromSuite.length) {
      errors.push(`${slug}: acceptance.json claims checks the suite does not make: ${match.missingFromSuite.join(' | ')}`);
    }
  }

  return errors;
}
```

- [ ] **Step 4: Confirm the tests pass**

Run: `node --test server/test/validate-corpus.test.mjs`
Expected: PASS — 8 tests

- [ ] **Step 5: Add the CLI runner**

Create `tools/validate-corpus.mjs`:

```javascript
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { validateProblemDir, checkSyllabus } from '../server/lib/validate-corpus.mjs';
import { CORPUS, ROOT } from '../server/lib/paths.mjs';

const SYLLABUS = JSON.parse(readFileSync(join(ROOT, 'corpus', '_syllabus.json'), 'utf8'));

let errors = [];
const tagging = {};

for (const slug of existsSync(CORPUS) ? readdirSync(CORPUS) : []) {
  const dir = join(CORPUS, slug);
  if (slug.startsWith('_') || !statSync(dir).isDirectory()) continue;
  errors.push(...validateProblemDir(dir));
  const meta = JSON.parse(readFileSync(join(dir, 'problem.json'), 'utf8'));
  tagging[slug] = meta.syllabus ?? [];
}

const syl = checkSyllabus(SYLLABUS.items.map((i) => i.id), tagging);
for (const item of syl.uncovered) errors.push(`syllabus item ${item} is not exercised by any problem`);
for (const u of syl.unknown) errors.push(`${u.problem} tags unknown syllabus item ${u.item}`);

if (errors.length) {
  console.error(`corpus validation FAILED — ${errors.length} problem(s):\n` + errors.map((e) => `  - ${e}`).join('\n'));
  process.exit(1);
}
console.log('corpus validation passed');
```

- [ ] **Step 6: Seed the syllabus**

Create `corpus/_syllabus.json` with all **34** items from
`docs/specs/2026-08-18-standard-and-syllabus.md` Part 2, shape:

```json
{
  "standard_version": "1.0",
  "items": [
    { "id": "A1", "group": "Modelling primitives", "title": "Enum carrying state and behaviour" },
    { "id": "A2", "group": "Modelling primitives", "title": "Value object / record, immutable by construction" }
  ]
}
```

All 34 ids: A1–A7, B1–B8, C1–C5, D1–D4, E1–E5, F1–F5. Copy the titles verbatim from the spec.

- [ ] **Step 7: Run the validator against the empty corpus**

Run: `node tools/validate-corpus.mjs`
Expected: FAILS, listing all 34 items as uncovered. **That is correct** — an empty corpus should not
validate. This proves the gate is live before any authoring begins.

- [ ] **Step 8: Snapshot** — `node tools/snapshot.mjs "corpus-1-validator"`

---

### Task 2: Parking Lot — the worked reference problem

Author this one **first and alone**. It becomes the model every other problem copies, so its shape
matters more than its content. Do not parallelise other problems until this passes validation.

**Files:** everything under `corpus/parking-lot/`

- [ ] **Step 1: `contract/VehicleType.java`, `Vehicle.java`, `Ticket.java`, `Receipt.java`**

```java
// contract/VehicleType.java — GIVEN. Do not edit.
public enum VehicleType { MOTORBIKE, CAR, TRUCK }
```

```java
// contract/Vehicle.java — GIVEN. Do not edit.
public record Vehicle(String registration, VehicleType type) {}
```

```java
// contract/Ticket.java — GIVEN. Do not edit.
public record Ticket(String ticketId, String spotId, java.time.Instant entryTime) {}
```

```java
// contract/Receipt.java — GIVEN. Do not edit.
public record Receipt(String ticketId, long feeMinor, java.time.Duration stay) {}
```

- [ ] **Step 2: `contract/ParkingLotApi.java`**

```java
import java.time.Instant;

/**
 * The entry points the grader calls. Implement this however you like.
 *
 * Your internal design is entirely yours: name your classes, fields and helpers
 * whatever you want and split them however you want. Only these signatures are
 * fixed, because the grader has to be able to call something.
 *
 * Stated conventions, so nothing is guessed:
 *  - A MOTORBIKE needs 1 standard spot, a CAR 1, a TRUCK 2 adjacent spots.
 *  - Fees are charged per STARTED hour: 1 minute costs one hour.
 *  - Fee per started hour, in minor units: MOTORBIKE 1000, CAR 2000, TRUCK 4000.
 *  - park() throws IllegalStateException when nothing suitable is free.
 *  - unpark() throws IllegalArgumentException for an unknown or already-used ticket.
 */
public interface ParkingLotApi {

    Ticket park(Vehicle vehicle, Instant entryTime);

    Receipt unpark(String ticketId, Instant exitTime);

    int availableSpots(VehicleType type);
}
```

**Note how much is stated.** Every arbitrary convention — rounding direction, prices, which exception —
is given. That is the mitigation for the one residual risk named in the deterministic-contract spec:
a candidate must never fail on an unstated rule.

- [ ] **Step 3: `contract/Entry.java`**

```java
/**
 * The ONLY place the grader needs to know your class names.
 * Everything else in your design is yours to name freely.
 */
public final class Entry {

    /** A lot with 10 standard spots on one floor. Return your implementation. */
    public static ParkingLotApi create() {
        return new ParkingLot(10);   // ← EDIT THIS LINE: your class, your constructor
    }

    private Entry() {}
}
```

- [ ] **Step 4: `scaffold/ParkingLot.java`** — the candidate's starting point

```java
/**
 * Your implementation. Rename it, split it, restructure it — anything you like,
 * as long as Entry.create() still returns something implementing ParkingLotApi.
 */
public class ParkingLot implements ParkingLotApi {

    public ParkingLot(int standardSpots) {
        // your state here
    }

    @Override
    public Ticket park(Vehicle vehicle, java.time.Instant entryTime) {
        throw new UnsupportedOperationException("not implemented");
    }

    @Override
    public Receipt unpark(String ticketId, java.time.Instant exitTime) {
        throw new UnsupportedOperationException("not implemented");
    }

    @Override
    public int availableSpots(VehicleType type) {
        throw new UnsupportedOperationException("not implemented");
    }
}
```

- [ ] **Step 5: `tests_base/ParkingLotBaseTest.java`**

Binds only to contract types. Note it never names `ParkingLot` and never reads a field.

```java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.time.Instant;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ParkingLotBaseTest {

    private static final Instant T0 = Instant.parse("2026-08-18T10:00:00Z");

    @Test
    @DisplayName("parks a car and issues a ticket recording spot and entry time")
    void parksACar() {
        ParkingLotApi api = Entry.create();
        Ticket t = api.park(new Vehicle("KA01AB1234", VehicleType.CAR), T0);
        assertNotNull(t.ticketId(), "a ticket must carry an id");
        assertNotNull(t.spotId(), "a ticket must record which spot was taken");
        assertEquals(T0, t.entryTime());
    }

    @Test
    @DisplayName("reduces available spots after parking")
    void reducesAvailability() {
        ParkingLotApi api = Entry.create();
        int before = api.availableSpots(VehicleType.CAR);
        api.park(new Vehicle("KA01AB1234", VehicleType.CAR), T0);
        assertEquals(before - 1, api.availableSpots(VehicleType.CAR));
    }

    @Test
    @DisplayName("rejects entry when no suitable spot is free")
    void rejectsWhenFull() {
        ParkingLotApi api = Entry.create();
        int capacity = api.availableSpots(VehicleType.CAR);
        for (int i = 0; i < capacity; i++) {
            api.park(new Vehicle("CAR" + i, VehicleType.CAR), T0);
        }
        assertEquals(0, api.availableSpots(VehicleType.CAR));
        assertThrows(IllegalStateException.class,
                () -> api.park(new Vehicle("ONE-TOO-MANY", VehicleType.CAR), T0));
    }

    @Test
    @DisplayName("charges one started hour for a stay under an hour")
    void chargesAStartedHour() {
        ParkingLotApi api = Entry.create();
        Ticket t = api.park(new Vehicle("KA01AB1234", VehicleType.CAR), T0);
        Receipt r = api.unpark(t.ticketId(), T0.plus(Duration.ofMinutes(1)));
        assertEquals(2000, r.feeMinor(), "a car pays 2000 per started hour");
        assertEquals(Duration.ofMinutes(1), r.stay());
    }

    @Test
    @DisplayName("charges per started hour for a longer stay")
    void chargesPerStartedHour() {
        ParkingLotApi api = Entry.create();
        Ticket t = api.park(new Vehicle("KA01AB1234", VehicleType.CAR), T0);
        Receipt r = api.unpark(t.ticketId(), T0.plus(Duration.ofMinutes(61)));
        assertEquals(4000, r.feeMinor(), "61 minutes is two started hours");
    }

    @Test
    @DisplayName("charges by vehicle type")
    void chargesByType() {
        ParkingLotApi api = Entry.create();
        Ticket bike = api.park(new Vehicle("BIKE1", VehicleType.MOTORBIKE), T0);
        assertEquals(1000, api.unpark(bike.ticketId(), T0.plusSeconds(60)).feeMinor());
    }

    @Test
    @DisplayName("frees the spot on exit so it can be reused")
    void freesTheSpot() {
        ParkingLotApi api = Entry.create();
        int before = api.availableSpots(VehicleType.CAR);
        Ticket t = api.park(new Vehicle("KA01AB1234", VehicleType.CAR), T0);
        api.unpark(t.ticketId(), T0.plusSeconds(60));
        assertEquals(before, api.availableSpots(VehicleType.CAR));
    }

    @Test
    @DisplayName("rejects an unknown ticket id on exit")
    void rejectsUnknownTicket() {
        ParkingLotApi api = Entry.create();
        assertThrows(IllegalArgumentException.class, () -> api.unpark("no-such-ticket", T0));
    }

    @Test
    @DisplayName("rejects the same ticket being used twice")
    void rejectsDoubleExit() {
        ParkingLotApi api = Entry.create();
        Ticket t = api.park(new Vehicle("KA01AB1234", VehicleType.CAR), T0);
        api.unpark(t.ticketId(), T0.plusSeconds(60));
        assertThrows(IllegalArgumentException.class, () -> api.unpark(t.ticketId(), T0.plusSeconds(120)));
    }

    @Test
    @DisplayName("a truck occupies two spots")
    void truckTakesTwo() {
        ParkingLotApi api = Entry.create();
        int before = api.availableSpots(VehicleType.CAR);
        api.park(new Vehicle("TRUCK1", VehicleType.TRUCK), T0);
        assertTrue(api.availableSpots(VehicleType.CAR) == before - 2,
                "a truck needs two standard spots");
    }
}
```

- [ ] **Step 6: `acceptance.json`** — mirrors the suite exactly

```json
{
  "checks": [
    "parks a car and issues a ticket recording spot and entry time",
    "reduces available spots after parking",
    "rejects entry when no suitable spot is free",
    "charges one started hour for a stay under an hour",
    "charges per started hour for a longer stay",
    "charges by vehicle type",
    "frees the spot on exit so it can be reused",
    "rejects an unknown ticket id on exit",
    "rejects the same ticket being used twice",
    "a truck occupies two spots"
  ]
}
```

- [ ] **Step 7: `reference/src/*.java`** — a solution with real seams

Write it with the seams the curveballs will test: a `PricingPolicy` interface keyed by
`VehicleType`, and a `SpotAllocator` interface. It must pass every base test. Keep classes small.
Include `reference/DECISION_LOG.md` explaining why each seam exists — that file is the Phase 5
answer key.

- [ ] **Step 8: Prove the reference passes its own suite**

```bash
mkdir -p workspace/_ref_check
cp corpus/parking-lot/contract/*.java corpus/parking-lot/reference/src/*.java workspace/_ref_check/
node -e "import('./server/lib/junit.mjs').then(async m => { const r = await m.runSuite({ attemptRoot: 'workspace/_ref_check', testDirs: ['corpus/parking-lot/tests_base'] }); console.log(JSON.stringify({compiled: r.compiled, summary: r.summary, failures: r.results.filter(x => !x.passed)}, null, 2)); })"
```

Expected: `compiled: true`, `summary.green: true`, `total: 10`, no failures.
**If the reference cannot pass, the test is wrong — fix the test.** This is hard rule 2.

- [ ] **Step 9: Author three curveballs**

`01-motorbikes-share-a-spot` — two motorbikes may share one standard spot. Tests allocation is a policy.
`02-tiered-pricing` — first hour free, then tiered by type. Tests pricing is a Strategy.
`03-nearest-spot` — allocate the lowest-numbered free spot; add `String nextSpotFor(VehicleType)` via
`contract-delta/`. Tests the allocator seam.

Each needs `REQUIREMENT-CHANGE.md` in plain language, a test file binding only to contract+delta, and
`budget.json` with a **measured** budget per the procedure at the top of this document.

- [ ] **Step 10: `problem.json`** with syllabus tags

Tag honestly — only what this problem genuinely exercises. Parking Lot covers roughly:
`A1, A2, A4, A5, A6, A7, B1, B2, C1, C2, C3, D1, D2, F1, F2, F5`.

- [ ] **Step 11: Validate**

Run: `node tools/validate-corpus.mjs`
Expected: the only remaining errors are uncovered syllabus items from the 19 unwritten problems.
**Zero errors mentioning `parking-lot`.**

- [ ] **Step 12: Clean up and snapshot**

```bash
rm -rf workspace/_ref_check
node tools/snapshot.mjs "corpus-2-parking-lot"
```

---

### Tasks 3–21: the remaining nineteen problems

**Only start these once Parking Lot passes validation.** Each follows Task 2's structure exactly and
may be authored by an independent agent owning only its own `corpus/<slug>/` directory.

| # | Slug | Tier | Sealed | Concurrency | Contract shape (sketch) |
|---|---|---|---|---|---|
| 3 | `vending-machine` | 1 | | | `insertCoin(Coin)`, `select(String)` → `DispenseResult`, `refund()` → `List<Coin>` |
| 4 | `snake-and-ladder` | 1 | | | `roll(int)` → `MoveResult`, `positionOf(String)`, `winner()` |
| 5 | `shopping-cart` | 1 | | | `add(Sku, int)`, `remove(Sku, int)`, `total()` → `Money`, `applyCoupon(String)` |
| 6 | `elevator` | 2 | | **yes** | `hallCall(int, Direction)`, `carCall(int, int)`, `step()`, `status(int)` |
| 7 | `movie-booking` | 2 | **yes** | **yes** | `hold(ShowId, List<SeatId>)`, `confirm(HoldId)`, `available(ShowId)` |
| 8 | `food-ordering` | 2 | **yes** | | `place(Order)`, `advance(OrderId)`, `track(OrderId)` → `OrderState` |
| 9 | `tic-tac-toe` | 2 | | | `play(int, int)` → `MoveResult`, `state()` → `GameState` |
| 10 | `library` | 2 | | | `borrow(Isbn, MemberId, Instant)`, `giveBack(LoanId, Instant)` → `Fine` |
| 11 | `splitwise` | 2 | | | `addExpense(Expense)`, `balanceBetween(UserId, UserId)`, `settle(...)` |
| 12 | `rate-limiter` | 3 | | **yes** | `tryAcquire(ClientKey, Instant)` → `Decision`, `configure(ClientKey, List<Rule>)` |
| 13 | `notification-service` | 3 | **yes** | **yes** | `send(Notification)` → `DeliveryReceipt`, `subscribe(...)`, retry policy |
| 14 | `file-system` | 3 | | | `mkdir(String)`, `write(String, String)`, `read(String)`, `ls(String)` |
| 15 | `lru-cache` | 3 | | **yes** | `put(K,V)`, `get(K)` → `Optional<V>`, custom eviction policy seam |
| 16 | `logger` | 3 | **yes** | **yes** | `log(Level, String)`, `addSink(...)`, level filtering |
| 17 | `cost-explorer` | 3 | | | `monthlyCost(SubscriptionId, YearMonth)`, `annualProjection(...)` |
| 18 | `rating-system` | 2 | | | `rate(EntityId, UserId, int)`, `average(EntityId)`, `topN(int)` |
| 19 | `middleware-router` | 3 | | | `register(String, Handler)`, `use(Middleware)`, `handle(Request)` → `Response` |
| 20 | `customer-satisfaction` | 2 | **yes** | | `submit(Survey)`, `scoreFor(AgentId)`, `trend(...)` |
| 21 | `trip-state-machine` | 3 | **yes** | | `handle(TripEvent)` → `TripState`, `history()` → `List<Transition>` |

**Sealed problems (7, 8, 13, 16, 20, 21)** still need every artifact — they are used in full rounds.
`problem.json` must set `"sealed": true`, which makes `forDrill()` refuse them.

**Concurrency problems** additionally need `tests_concurrency/` using `DojoConcurrency`, and must tag
E-group syllabus items. Verify each stress test actually catches a naive non-thread-safe implementation
before accepting it — the harness convention is proven, but each problem's test must earn it.

**Syllabus coverage is a collective obligation.** Before the final problem is accepted,
`node tools/validate-corpus.mjs` must report zero uncovered items. Deliberately place the rarer items:
`B8` template/lifecycle hook → `middleware-router` or `food-ordering`; `D4` bulk/pagination shape →
`cost-explorer` or `rating-system`; `E5` idempotency under retry → `notification-service`;
`C5` interface segregation → `elevator`.

---

## Self-review

**Coverage of the binding specs.** The contract-first rule from the deterministic-contract spec is
enforced mechanically by `checkTestBinding` and `findFieldAccess` (Task 1), not left to authoring
discipline. `Entry.create()` presence is checked. `acceptance.json` two-way matching prevents the
visible checklist drifting from the hidden assertions. Syllabus coverage and closure are checked
against `corpus/_syllabus.json`.

**The gate that matters most** is Task 2 Step 8, repeated for every problem: the reference solution
must pass its own suite. Every other check is about shape; this one is about truth. A suite its own
reference cannot satisfy is a defect that would fail the user for the author's mistake.

**Known limitation, stated rather than hidden.** `referencedTypes` is a regex heuristic, not a Java
parser. It maintains a `NOT_TYPES` allow-list and skips `java.*` imports and locally-declared types, so
it can produce a false positive on an unusual construct. That failure mode is **safe**: it fails the
build with a named type, and the author either adds the type to the contract or adds it to the
allow-list with a comment. A false *negative* would be dangerous; a false positive is merely noisy.

**Ordering constraint.** Task 1 before all authoring, Task 2 before Tasks 3–21, and Tasks 3–21 are
mutually independent. That is what makes parallel authoring safe.
