Sfoglia il codice sorgente

test(explore): #1500 regression fixtures for budget allocation (CG-6)

Two permanent fixtures pinning the failure mode from issue #1500 — explore
spending its byte envelope on files that merely name-collide with the query.
BOTH FAIL TODAY, by design: they document the bug and become the pass gate
for CG-10 (scoring) + CG-12 (proportional allocation).

__tests__/fixtures/payroll-go/ — a synthetic Go service mirroring the
reporter's shape: generated FKIT CRUD beside a hand-written payroll use-case,
entered from an HTTP route. Half the generated tree carries ORDINARY names
detectable only by their `// Code generated ... DO NOT EDIT.` header (the
#1500 case, and end-to-end cover for CG-5); `payrollpb/*.pb.go` covers the
path-detectable channel. BuildPayslip, Upsert and Store each exist twice,
generated and hand-written. cycle.go sits above the whole-file window so it
clips; the generated files sit below it so they ship whole.

Asking "how does payroll cycle create and calculate payslips?" — naming none
of the answering symbols — the generated CRUD delivers 57.4% of the envelope
against the hand-written layer's 25.6%, all of the latter domain types.
cycle.go is allocated the single largest slice (30.6%) and delivers ZERO: the
hard ceiling drops its whole section. runPayrollCycleAll, the hand-written
BuildPayslip and the real Upsert never reach the agent.

The second fixture is this repo, "how does explore allocate its output budget
across files", where scripts/agent-eval/*.mjs take 71.8% against tools.ts's
18.5% despite scoring 4.6x lower. It reads the live index, so its assertions
are relative rather than fixed percentages.

- scripts/agent-eval/probe-allocation.mjs — per-file budget-share probe,
  driving the CG-4 diagnostic through a JSONL sidecar so it measures the
  shipping allocator. Fixture entries are hermetic (copy + re-index per run,
  verified byte-identical across runs); exits 1 while any assertion fails.
- scripts/agent-eval/allocation-fixtures.json — both fixtures declared, with
  the 2026-08-03 baselines.
- __tests__/explore-allocation-1500.test.ts — fixture-shape assertions green
  today; the allocation assertions held as `it.fails` so the suite stays green
  while the bug is open and goes RED the moment it is fixed.

Also documented and deliberately left unfixed: runPayrollCycleAll's
`s.store.Upsert` edge resolves to the GENERATED Store.Upsert, not the
hand-written one — same-name method resolution across two packages picks the
wrong receiver. It is upstream of the allocation bug, so it belongs with
CG-10's scoring work.

Refs #1500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 mese fa
parent
commit
bd86ad2061
25 ha cambiato i file con 2687 aggiunte e 0 eliminazioni
  1. 256 0
      __tests__/explore-allocation-1500.test.ts
  2. 95 0
      __tests__/fixtures/payroll-go/README.md
  3. 35 0
      __tests__/fixtures/payroll-go/cmd/payrolld/main.go
  4. 3 0
      __tests__/fixtures/payroll-go/go.mod
  5. 159 0
      __tests__/fixtures/payroll-go/internal/domain/payroll/payslip.go
  6. 84 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/employee/contract.go
  7. 88 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/employee/employee.go
  8. 60 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/payroll/calculate.go
  9. 80 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/payroll/dto.go
  10. 116 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payroll_cycle.go
  11. 129 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payslip.go
  12. 95 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/payroll/store.go
  13. 76 0
      __tests__/fixtures/payroll-go/internal/gen/fkit/timesheet/timesheet.go
  14. 139 0
      __tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll.pb.go
  15. 104 0
      __tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll_grpc.pb.go
  16. 18 0
      __tests__/fixtures/payroll-go/internal/platform/clock/clock.go
  17. 119 0
      __tests__/fixtures/payroll-go/internal/store/payslipstore/store.go
  18. 96 0
      __tests__/fixtures/payroll-go/internal/transport/httpapi/payroll_handler.go
  19. 19 0
      __tests__/fixtures/payroll-go/internal/transport/httpapi/router.go
  20. 227 0
      __tests__/fixtures/payroll-go/internal/usecase/payroll/cycle.go
  21. 150 0
      __tests__/fixtures/payroll-go/internal/usecase/payroll/payslip_builder.go
  22. 53 0
      __tests__/fixtures/payroll-go/internal/usecase/payroll/prorate.go
  23. 78 0
      docs/design/explore-budget-allocation.md
  24. 112 0
      scripts/agent-eval/allocation-fixtures.json
  25. 296 0
      scripts/agent-eval/probe-allocation.mjs

+ 256 - 0
__tests__/explore-allocation-1500.test.ts

@@ -0,0 +1,256 @@
+/**
+ * Regression fixture for GitHub issue #1500 / epic CG-1 — relevance-proportional
+ * explore budget allocation.
+ *
+ * The reporter's repo is a Go service whose GENERATED FKIT CRUD layer sits beside
+ * the hand-written use-case that does the real work. Asking an architecture
+ * question that doesn't name the exact use-case ("how does payroll cycle create
+ * and calculate payslips?") spends the explore envelope on the generated CRUD,
+ * because the generated layer name-collides on every term in the question while
+ * the hand-written workflow is one big file that gets clipped.
+ *
+ * `__tests__/fixtures/payroll-go/` reproduces that shape permanently. This suite
+ * is in two halves:
+ *
+ *  1. **Fixture shape** — green today. These pin the properties the fixture must
+ *     keep for the gate below to mean anything: the generated/hand-written split
+ *     (including the ordinary-named generated files only a CONTENT header betrays,
+ *     which is the #1500 case), the deliberate name collisions, and the
+ *     runPayrollCycleAll → BuildPayslip → Upsert chain resolving end-to-end. If
+ *     the fixture rots, these fail first and say so.
+ *
+ *  2. **Budget allocation** — the gate, written with `it.fails` because it
+ *     DOCUMENTS A BUG THAT IS STILL OPEN. Vitest passes an `it.fails` test only
+ *     while its body throws, so the suite is green today and goes RED the moment
+ *     allocation is fixed (CG-10 scoring + CG-12 proportional bytes).
+ *     **When it goes red, delete the `.fails` — do not delete the test.**
+ *
+ * The same assertions run outside vitest, against the built dist and with the
+ * full CG-4 per-file diagnostic, via `node scripts/agent-eval/probe-allocation.mjs`
+ * (declared in `scripts/agent-eval/allocation-fixtures.json`).
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
+import { isGeneratedFile, hasGeneratedHeader } from '../src/extraction/generated-detection';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
+
+/** The question a newcomer asks — names none of the symbols that answer it. */
+const QUERY = 'how does payroll cycle create and calculate payslips?';
+
+/** The hand-written workflow: what the query is actually about. */
+const ANSWER_PREFIXES = [
+  'internal/usecase/',
+  'internal/store/',
+  'internal/transport/',
+  'internal/domain/',
+  'cmd/',
+];
+/** The generated CRUD/DTO layer: what wins the envelope today. */
+const GENERATED_PREFIX = 'internal/gen/';
+
+const startsWithAny = (p: string, prefixes: string[]) => prefixes.some((x) => p.startsWith(x));
+
+describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', () => {
+  let testDir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+  let response: string;
+  /** Delivered source bytes per file, attributed from the final response. */
+  let bytes: Map<string, number>;
+
+  beforeAll(async () => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1500-'));
+    fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+    // A stray index in the checked-in tree would be copied in and reused.
+    fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+
+    cg = CodeGraph.initSync(testDir);
+    await cg.indexAll();
+    handler = new ToolHandler(cg);
+
+    const result = await handler.execute('codegraph_explore', { query: QUERY });
+    response = result.content?.[0]?.text ?? '';
+    bytes = attributeSourceBytes(response);
+  }, 120_000);
+
+  afterAll(() => {
+    if (cg) cg.destroy();
+    if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+  });
+
+  // ── 1. Fixture shape ──────────────────────────────────────────────────────
+
+  describe('fixture shape', () => {
+    it('indexes as a Go project with both layers present', () => {
+      const files = cg.getFiles().map((f) => f.path);
+      expect(files.filter((p) => p.endsWith('.go')).length).toBeGreaterThanOrEqual(15);
+      expect(files.some((p) => p.startsWith(GENERATED_PREFIX))).toBe(true);
+      expect(files.some((p) => p.startsWith('internal/usecase/'))).toBe(true);
+    });
+
+    it('flags every generated file and no hand-written one', () => {
+      for (const file of cg.getFiles()) {
+        expect(file.generated, `${file.path} generated flag`).toBe(
+          file.path.startsWith(GENERATED_PREFIX),
+        );
+      }
+    });
+
+    it('carries generated files that ONLY a content header betrays — the #1500 case', () => {
+      // Half the generated tree has ordinary names (`payslip.go`, `store.go`).
+      // Path-only detection misses them; the CG-5 content check is what catches
+      // them. Without these the fixture would be a .pb.go fixture, not a #1500 one.
+      const contentOnly = [
+        'internal/gen/fkit/payroll/payslip.go',
+        'internal/gen/fkit/payroll/payroll_cycle.go',
+        'internal/gen/fkit/payroll/store.go',
+        'internal/gen/fkit/payroll/calculate.go',
+        'internal/gen/fkit/payroll/dto.go',
+        'internal/gen/fkit/employee/employee.go',
+        'internal/gen/fkit/timesheet/timesheet.go',
+      ];
+      for (const rel of contentOnly) {
+        const source = fs.readFileSync(path.join(testDir, rel), 'utf-8');
+        expect(isGeneratedFile(rel), `${rel} must NOT be detectable by path`).toBe(false);
+        expect(hasGeneratedHeader(source), `${rel} must be detectable by header`).toBe(true);
+        expect(cg.getFile(rel)?.generated, `${rel} indexed flag`).toBe(true);
+      }
+      // …beside the conventional path-detectable ones, so both channels are covered.
+      expect(isGeneratedFile('internal/gen/payrollpb/payroll.pb.go')).toBe(true);
+    });
+
+    it('collides the generated layer with the hand-written one by name', () => {
+      // A naive scorer sees two BuildPayslips and two Upserts and has no reason
+      // to prefer the one that implements the business rule.
+      for (const name of ['BuildPayslip', 'Upsert', 'Store']) {
+        const files = new Set(cg.getNodesByName(name).map((n) => n.filePath));
+        expect([...files].some((p) => p.startsWith(GENERATED_PREFIX)), `${name} generated`).toBe(true);
+        expect([...files].some((p) => !p.startsWith(GENERATED_PREFIX)), `${name} hand-written`).toBe(true);
+      }
+    });
+
+    it('resolves the hand-written workflow chain end-to-end in the graph', () => {
+      const calleesOf = (name: string, file: string) => {
+        const node = cg.getNodesByName(name).find((n) => n.filePath === file);
+        expect(node, `${name} in ${file}`).toBeTruthy();
+        return cg
+          .getOutgoingEdges(node!.id)
+          .filter((e) => e.kind === 'calls')
+          .map((e) => cg.getNode(e.target))
+          .filter((n): n is NonNullable<typeof n> => !!n);
+      };
+
+      // handler → use-case
+      expect(
+        calleesOf('RunCycle', 'internal/transport/httpapi/payroll_handler.go')
+          .some((n) => n.name === 'RunCycle' && n.filePath === 'internal/usecase/payroll/cycle.go'),
+      ).toBe(true);
+
+      // use-case → the workflow
+      expect(
+        calleesOf('RunCycle', 'internal/usecase/payroll/cycle.go')
+          .some((n) => n.name === 'runPayrollCycleAll'),
+      ).toBe(true);
+
+      // the workflow → build + persist
+      const workflow = calleesOf('runPayrollCycleAll', 'internal/usecase/payroll/cycle.go');
+      expect(
+        workflow.some((n) => n.name === 'BuildPayslip' && n.filePath === 'internal/usecase/payroll/payslip_builder.go'),
+        'runPayrollCycleAll must reach the hand-written BuildPayslip',
+      ).toBe(true);
+      expect(workflow.some((n) => n.name === 'Upsert'), 'runPayrollCycleAll must reach an Upsert').toBe(true);
+    });
+
+    it('routes an HTTP entry point into the workflow', () => {
+      const router = cg.getNodesInFile('internal/transport/httpapi/router.go');
+      expect(router.some((n) => n.kind === 'route' || n.name === 'NewRouter')).toBe(true);
+    });
+
+    it('sizes the two layers so the size-driven render split actually bites', () => {
+      // The mechanism the epic is about: a small file ships WHOLE, a large one
+      // falls through to clipped clusters. The workflow file must stay above the
+      // whole-file window and the generated files below it, or the fixture stops
+      // reproducing anything.
+      const lines = (rel: string) => fs.readFileSync(path.join(testDir, rel), 'utf-8').split('\n').length;
+      expect(lines('internal/usecase/payroll/cycle.go')).toBeGreaterThan(220);
+      for (const rel of ['internal/gen/fkit/payroll/payslip.go', 'internal/gen/fkit/payroll/payroll_cycle.go']) {
+        expect(lines(rel)).toBeLessThan(220);
+      }
+    });
+
+    it('answers the query at all', () => {
+      expect(response.length).toBeGreaterThan(1000);
+      expect(bytes.size).toBeGreaterThan(0);
+    });
+  });
+
+  // ── 2. Budget allocation — the open bug ───────────────────────────────────
+
+  describe('budget allocation', () => {
+    const share = (predicate: (p: string) => boolean) => {
+      let total = 0;
+      for (const [file, n] of bytes) if (predicate(file)) total += n;
+      return total / response.length;
+    };
+    const answerShare = () => share((p) => startsWithAny(p, ANSWER_PREFIXES));
+    const generatedShare = () => share((p) => p.startsWith(GENERATED_PREFIX));
+
+    /**
+     * BASELINE 2026-08-03 (very-tiny tier, 13,000-char budget): 23,020 chars
+     * allocated against it, cut to 16,011 by the 19,500 hard ceiling. The
+     * generated CRUD delivers 57.4%; the hand-written layer delivers 25.6%, all
+     * of it domain types. `cycle.go` is allocated the single largest slice
+     * (7,052 chars, 30.6%) and delivers ZERO — the ceiling drops its whole
+     * section — so runPayrollCycleAll, the hand-written BuildPayslip and the
+     * real Upsert never reach the agent at all.
+     *
+     * Each `it.fails` below passes ONLY while that is still true.
+     * ⚠ When one goes red, the bug is fixed: remove `.fails`, keep the test.
+     */
+    it.fails('CG-12 GATE: concentrates the envelope on the hand-written workflow', () => {
+      expect(answerShare()).toBeGreaterThanOrEqual(0.55);
+    });
+
+    it.fails('CG-12 GATE: does not spend the envelope on the generated CRUD', () => {
+      expect(generatedShare()).toBeLessThanOrEqual(0.25);
+    });
+
+    it.fails('CG-12 GATE: delivers the workflow file it allocated the most bytes to', () => {
+      expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0);
+    });
+
+    it.fails('CG-12 GATE: delivers the calculation the question asks about', () => {
+      expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
+    });
+
+    it.fails('CG-12 GATE: puts the hand-written chain in the response, not its generated twin', () => {
+      // Bare `BuildPayslip`/`Upsert` also match the generated collisions — these
+      // needles are unique to the hand-written chain.
+      expect(response).toContain('runPayrollCycleAll');
+      expect(response).toContain('func (s *Service) BuildPayslip');
+      expect(response).toContain('s.store.Upsert(ctx, slip)');
+    });
+
+    it('records the shape of the failure so a regression is legible', () => {
+      // Not a gate — an assertion-free-ish snapshot of WHY the gates above fail,
+      // so a future change that shifts the numbers shows up in the diff rather
+      // than silently flipping an it.fails.
+      const generated = generatedShare();
+      const answer = answerShare();
+      const workflow = bytes.get('internal/usecase/payroll/cycle.go') ?? 0;
+      expect({
+        generatedWinsEnvelope: generated > answer,
+        workflowFileDeliversNothing: workflow === 0,
+      }).toEqual({
+        generatedWinsEnvelope: true,
+        workflowFileDeliversNothing: true,
+      });
+    });
+  });
+});

+ 95 - 0
__tests__/fixtures/payroll-go/README.md

@@ -0,0 +1,95 @@
+# payroll-go — the #1500 regression fixture
+
+A synthetic Go service reproducing the repo shape from [issue #1500](https://github.com/colbymchenry/codegraph/issues/1500):
+**generated CRUD sitting beside the hand-written use-case that does the real work.**
+
+This tree is a fixture, not a program. It never compiles or runs — it exists to be
+indexed. Keep it valid, idiomatic Go anyway: the extractor's output is the whole point.
+
+## The shape
+
+```
+cmd/payrolld/main.go                        wires the service
+internal/transport/httpapi/                 HTTP entry point → use-case
+internal/usecase/payroll/                   ← THE ANSWER. Hand-written workflow:
+  cycle.go                                    runPayrollCycleAll (227 lines)
+  payslip_builder.go                          BuildPayslip — the actual pay calculation
+  prorate.go
+internal/domain/payroll/payslip.go          hand-written domain types
+internal/store/payslipstore/store.go        the real Upsert
+internal/platform/clock/clock.go
+
+internal/gen/fkit/payroll/                  ← THE NOISE. Generated CRUD, ORDINARY names:
+  payslip.go        CreatePayslip, GetPayslip, UpdatePayslip, a second BuildPayslip
+  payroll_cycle.go  CreatePayrollCycle, PayrollCycleCreateRequest, …
+  store.go          a second Upsert
+  calculate.go      CalculatePayrollCycleTotals, CalculatePayslipNet, …
+  dto.go
+internal/gen/fkit/employee/, timesheet/     more generated CRUD
+internal/gen/payrollpb/*.pb.go              generated, detectable by PATH
+```
+
+The chain the fixture is built around is `runPayrollCycleAll` → `BuildPayslip` → `Upsert`,
+entered from `POST /v1/payroll/cycles/{cycleID}/run`.
+
+## The three properties that make it a regression fixture
+
+1. **Generated files that only a CONTENT header betrays.** The `internal/gen/fkit/**`
+   files have ordinary names (`payslip.go`, `store.go`) and carry
+   `// Code generated by fkit v3.11.0. DO NOT EDIT.`. Path-only detection misses every
+   one of them — that is the #1500 case, and why CG-5 added the content check. The
+   `payrollpb/*.pb.go` files cover the path-detectable channel beside them.
+
+2. **Deliberate name collisions.** `BuildPayslip`, `Upsert` and `Store` each exist twice,
+   once generated and once hand-written. The generated layer also name-collides on every
+   term of the question below — `CreatePayslip`, `PayrollCycleCreateRequest`,
+   `CalculatePayrollCycleTotals` — so a scorer that rewards incidental name matches
+   surfaces the CRUD path.
+
+3. **A size split that drives the render mode.** `cycle.go` is deliberately over the
+   whole-file window (227 lines) so it falls through to clipped clusters; the generated
+   files are deliberately under it so they ship whole. Allocation follows file size, not
+   relevance. `__tests__/explore-allocation-1500.test.ts` pins both sides of that split —
+   if you edit these files, keep it.
+
+## The assertion
+
+Query: **"how does payroll cycle create and calculate payslips?"** — an architecture
+question that names none of the symbols that answer it. The budget should concentrate on
+the hand-written workflow. As of 2026-08-03 it does not:
+
+| | allocated | delivered |
+|---|---|---|
+| hand-written | 48.4% | **25.6%** (all of it domain types) |
+| generated CRUD | 39.9% | **57.4%** |
+
+`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers
+**zero** — the hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never
+renders at all. `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real
+`Upsert` never reach the agent.
+
+## Running it
+
+```bash
+npm run build
+node scripts/agent-eval/probe-allocation.mjs payroll-go   # exits 1 today, by design
+npx vitest run __tests__/explore-allocation-1500.test.ts  # green today, by design
+```
+
+The probe reports per-file budget share against `scripts/agent-eval/allocation-fixtures.json`.
+The vitest suite pins the fixture's shape and holds the allocation assertion as `it.fails` —
+green while the bug is open, red the moment it is fixed. See
+`docs/design/explore-budget-allocation.md`.
+
+## Known finding: the chain's `Upsert` edge resolves to the generated store
+
+`runPayrollCycleAll` calls `s.store.Upsert(ctx, slip)`, where `s.store` is a
+`*payslipstore.Store`. The graph resolves that edge to `internal/gen/fkit/payroll/store.go`
+— the **generated** `Store.Upsert` — not the hand-written one. Same-name method resolution
+across two packages that both define `Store.Upsert` picks the wrong receiver.
+
+This is a resolution defect, not a budget one, and it is left unfixed on purpose: it is
+upstream of the allocation bug (a wrong edge pulls the generated store into the subgraph
+and inflates its score), so it belongs with the scoring work in CG-10 rather than here.
+The test asserts only that the workflow reaches *an* `Upsert`, so tightening the resolver
+later will not break the fixture.

+ 35 - 0
__tests__/fixtures/payroll-go/cmd/payrolld/main.go

@@ -0,0 +1,35 @@
+package main
+
+import (
+	"log"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/example/payroll-svc/internal/platform/clock"
+	"github.com/example/payroll-svc/internal/store/payslipstore"
+	"github.com/example/payroll-svc/internal/transport/httpapi"
+	"github.com/example/payroll-svc/internal/usecase/payroll"
+)
+
+func main() {
+	addr := os.Getenv("LISTEN_ADDR")
+	if addr == "" {
+		addr = ":8080"
+	}
+
+	store := payslipstore.New()
+	svc := payroll.NewService(store, clock.System{})
+	router := httpapi.NewRouter(httpapi.NewPayrollHandler(svc))
+
+	srv := &http.Server{
+		Addr:              addr,
+		Handler:           router,
+		ReadHeaderTimeout: 5 * time.Second,
+	}
+
+	log.Printf("payrolld listening on %s", addr)
+	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+		log.Fatalf("payrolld: %v", err)
+	}
+}

+ 3 - 0
__tests__/fixtures/payroll-go/go.mod

@@ -0,0 +1,3 @@
+module github.com/example/payroll-svc
+
+go 1.22

+ 159 - 0
__tests__/fixtures/payroll-go/internal/domain/payroll/payslip.go

@@ -0,0 +1,159 @@
+package payroll
+
+import "time"
+
+// CycleStatus is the lifecycle state of a payroll cycle.
+type CycleStatus string
+
+const (
+	CycleOpen   CycleStatus = "open"
+	CycleClosed CycleStatus = "closed"
+)
+
+// ContractKind distinguishes the two pay models this service supports.
+type ContractKind string
+
+const (
+	ContractSalaried ContractKind = "salaried"
+	ContractHourly   ContractKind = "hourly"
+)
+
+// LineKind separates the two halves of a payslip.
+type LineKind string
+
+const (
+	LineEarning   LineKind = "earning"
+	LineDeduction LineKind = "deduction"
+)
+
+// Cycle is one payroll period.
+type Cycle struct {
+	ID           string
+	Start        time.Time
+	End          time.Time
+	Status       CycleStatus
+	ClosedAt     time.Time
+	ReopenReason string
+}
+
+// Line is a single earning or deduction on a payslip.
+type Line struct {
+	Code        string
+	Kind        LineKind
+	AmountCents int64
+}
+
+// Payslip is what a cycle produces for one employee.
+type Payslip struct {
+	CycleID        string
+	EmployeeID     string
+	Currency       string
+	PeriodFrom     time.Time
+	PeriodTo       time.Time
+	Lines          []Line
+	GrossCents     int64
+	DeductionCents int64
+	NetCents       int64
+	Underwater     bool
+	RunAt          time.Time
+	RunReason      string
+}
+
+// Timesheet is the approved unit count backing an hourly payslip.
+type Timesheet struct {
+	CycleID    string
+	EmployeeID string
+	Approved   bool
+	Units      int
+}
+
+// Allowance is a recurring earning attached to a contract.
+type Allowance struct {
+	Code        string
+	AmountCents int64
+	Prorated    bool
+}
+
+// Contract holds the pay terms for one employee.
+type Contract struct {
+	Kind                   ContractKind
+	Currency               string
+	RateCents              int64
+	PeriodRateCents        int64
+	OvertimeThresholdUnits int
+	OvertimeMultiplier     float64
+	Allowances             []Allowance
+	StartsOn               time.Time
+	EndsOn                 time.Time
+}
+
+// OverlapsWindow reports whether the contract is live at any point in the window.
+func (c Contract) OverlapsWindow(from, to time.Time) bool {
+	if !c.StartsOn.IsZero() && c.StartsOn.After(to) {
+		return false
+	}
+	if !c.EndsOn.IsZero() && c.EndsOn.Before(from) {
+		return false
+	}
+	return true
+}
+
+// PeriodUnits is the contractual unit count for a window, used when a salaried
+// employee has no approved timesheet.
+func (c Contract) PeriodUnits(from, to time.Time) int {
+	if to.Before(from) {
+		return 0
+	}
+	days := int(to.Sub(from).Hours()/24) + 1
+	return days * 8
+}
+
+// Deduction is a fixed or proportional subtraction from gross.
+type Deduction struct {
+	Code            string
+	FixedCents      int64
+	RateBasisPoints int
+}
+
+// AmountFor resolves a deduction against a gross amount.
+func (d Deduction) AmountFor(grossCents int64) int64 {
+	if d.FixedCents > 0 {
+		return d.FixedCents
+	}
+	return grossCents * int64(d.RateBasisPoints) / 10000
+}
+
+// TaxBand is one slice of a progressive tax schedule.
+type TaxBand struct {
+	UpToCents       int64
+	RateBasisPoints int
+}
+
+// Leave is an absence window.
+type Leave struct {
+	From   time.Time
+	To     time.Time
+	Unpaid bool
+}
+
+// Employee is the payroll view of a person.
+type Employee struct {
+	ID         string
+	Contract   Contract
+	Deductions []Deduction
+	TaxBands   []TaxBand
+	Leave      []Leave
+}
+
+// UnpaidLeaveCoversWindow reports whether unpaid leave swallows the whole window.
+func (e Employee) UnpaidLeaveCoversWindow(from, to time.Time) bool {
+	for _, l := range e.Leave {
+		if !l.Unpaid {
+			continue
+		}
+		if !l.From.After(from) && !l.To.Before(to) {
+			return true
+		}
+	}
+	return false
+}

+ 84 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/employee/contract.go

@@ -0,0 +1,84 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/employee/contract.fkit
+// Regenerate with: go run ./tools/fkitgen ./schema/employee
+
+package employee
+
+import (
+	"context"
+	"database/sql"
+	"time"
+)
+
+// ContractRow is the generated row type for table contract.
+type ContractRow struct {
+	ID              string
+	EmployeeID      string
+	Kind            string
+	Currency        string
+	RateCents       int64
+	PeriodRateCents int64
+	StartsOn        time.Time
+	EndsOn          time.Time
+	CreatedAt       time.Time
+	UpdatedAt       time.Time
+}
+
+// ContractCreateRequest is the generated create payload for table contract.
+type ContractCreateRequest struct {
+	EmployeeID      string    `json:"employeeId"`
+	Kind            string    `json:"kind"`
+	Currency        string    `json:"currency"`
+	RateCents       int64     `json:"rateCents"`
+	PeriodRateCents int64     `json:"periodRateCents"`
+	StartsOn        time.Time `json:"startsOn"`
+}
+
+// CreateContract inserts one contract row.
+func CreateContract(ctx context.Context, db *sql.DB, req ContractCreateRequest) (ContractRow, error) {
+	const q = `INSERT INTO contract (employee_id, kind, currency, rate_cents, period_rate_cents, starts_on)
+	           VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
+	return scanContract(db.QueryRowContext(ctx, q, req.EmployeeID, req.Kind, req.Currency,
+		req.RateCents, req.PeriodRateCents, req.StartsOn))
+}
+
+// GetContract selects one contract row by primary key.
+func GetContract(ctx context.Context, db *sql.DB, id string) (ContractRow, error) {
+	const q = `SELECT * FROM contract WHERE id = $1`
+	return scanContract(db.QueryRowContext(ctx, q, id))
+}
+
+// DeleteContract removes one contract row.
+func DeleteContract(ctx context.Context, db *sql.DB, id string) error {
+	const q = `DELETE FROM contract WHERE id = $1`
+	_, err := db.ExecContext(ctx, q, id)
+	return err
+}
+
+// ListContractsForEmployee selects contract rows for one employee.
+func ListContractsForEmployee(ctx context.Context, db *sql.DB, employeeID string) ([]ContractRow, error) {
+	const q = `SELECT * FROM contract WHERE employee_id = $1 ORDER BY starts_on DESC`
+	rows, err := db.QueryContext(ctx, q, employeeID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []ContractRow
+	for rows.Next() {
+		var r ContractRow
+		if err := rows.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents,
+			&r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func scanContract(row *sql.Row) (ContractRow, error) {
+	var r ContractRow
+	err := row.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents,
+		&r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt)
+	return r, err
+}

+ 88 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/employee/employee.go

@@ -0,0 +1,88 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/employee/employee.fkit
+// Regenerate with: go run ./tools/fkitgen ./schema/employee
+
+package employee
+
+import (
+	"context"
+	"database/sql"
+	"time"
+)
+
+// EmployeeRow is the generated row type for table employee.
+type EmployeeRow struct {
+	ID        string
+	Email     string
+	FullName  string
+	Status    string
+	HiredOn   time.Time
+	CreatedAt time.Time
+	UpdatedAt time.Time
+}
+
+// EmployeeCreateRequest is the generated create payload for table employee.
+type EmployeeCreateRequest struct {
+	Email    string `json:"email"`
+	FullName string `json:"fullName"`
+	Status   string `json:"status"`
+}
+
+// EmployeeUpdateRequest is the generated update payload for table employee.
+type EmployeeUpdateRequest struct {
+	FullName *string `json:"fullName,omitempty"`
+	Status   *string `json:"status,omitempty"`
+}
+
+// CreateEmployee inserts one employee row.
+func CreateEmployee(ctx context.Context, db *sql.DB, req EmployeeCreateRequest) (EmployeeRow, error) {
+	const q = `INSERT INTO employee (email, full_name, status) VALUES ($1, $2, $3) RETURNING *`
+	return scanEmployee(db.QueryRowContext(ctx, q, req.Email, req.FullName, req.Status))
+}
+
+// GetEmployee selects one employee row by primary key.
+func GetEmployee(ctx context.Context, db *sql.DB, id string) (EmployeeRow, error) {
+	const q = `SELECT * FROM employee WHERE id = $1`
+	return scanEmployee(db.QueryRowContext(ctx, q, id))
+}
+
+// UpdateEmployee patches one employee row.
+func UpdateEmployee(ctx context.Context, db *sql.DB, id string, req EmployeeUpdateRequest) (EmployeeRow, error) {
+	const q = `UPDATE employee SET full_name = COALESCE($2, full_name), status = COALESCE($3, status),
+	           updated_at = now() WHERE id = $1 RETURNING *`
+	return scanEmployee(db.QueryRowContext(ctx, q, id, req.FullName, req.Status))
+}
+
+// DeleteEmployee removes one employee row.
+func DeleteEmployee(ctx context.Context, db *sql.DB, id string) error {
+	const q = `DELETE FROM employee WHERE id = $1`
+	_, err := db.ExecContext(ctx, q, id)
+	return err
+}
+
+// ListEmployeesByStatus selects employee rows in one status.
+func ListEmployeesByStatus(ctx context.Context, db *sql.DB, status string) ([]EmployeeRow, error) {
+	const q = `SELECT * FROM employee WHERE status = $1 ORDER BY full_name`
+	rows, err := db.QueryContext(ctx, q, status)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []EmployeeRow
+	for rows.Next() {
+		var r EmployeeRow
+		if err := rows.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn,
+			&r.CreatedAt, &r.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func scanEmployee(row *sql.Row) (EmployeeRow, error) {
+	var r EmployeeRow
+	err := row.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn, &r.CreatedAt, &r.UpdatedAt)
+	return r, err
+}

+ 60 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/calculate.go

@@ -0,0 +1,60 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/payroll/aggregates.fkit
+// Regenerate with: go run ./tools/fkitgen ./schema/payroll
+
+package payroll
+
+import (
+	"context"
+	"database/sql"
+)
+
+// PayrollCycleTotals is the generated aggregate row for a payroll cycle.
+type PayrollCycleTotals struct {
+	CycleID        string
+	Payslips       int64
+	GrossCents     int64
+	DeductionCents int64
+	NetCents       int64
+}
+
+// CalculatePayrollCycleTotals runs the generated SUM aggregate over the
+// payslip rows of one cycle. It totals what is already stored; it does not
+// calculate any payslip.
+func CalculatePayrollCycleTotals(ctx context.Context, db *sql.DB, cycleID string) (PayrollCycleTotals, error) {
+	const q = `SELECT count(*), COALESCE(sum(gross_cents), 0), COALESCE(sum(deduction_cents), 0),
+	                  COALESCE(sum(net_cents), 0)
+	           FROM payslip WHERE cycle_id = $1`
+	var t PayrollCycleTotals
+	t.CycleID = cycleID
+	err := db.QueryRowContext(ctx, q, cycleID).Scan(&t.Payslips, &t.GrossCents, &t.DeductionCents, &t.NetCents)
+	return t, err
+}
+
+// CalculatePayslipNet recomputes net from the stored gross and deduction
+// columns of one row. Pure column arithmetic — no pay rules.
+func CalculatePayslipNet(row PayslipRow) int64 {
+	return row.GrossCents - row.DeductionCents
+}
+
+// CalculatePayrollCycleAverage averages the stored net over a cycle.
+func CalculatePayrollCycleAverage(ctx context.Context, db *sql.DB, cycleID string) (int64, error) {
+	totals, err := CalculatePayrollCycleTotals(ctx, db, cycleID)
+	if err != nil {
+		return 0, err
+	}
+	if totals.Payslips == 0 {
+		return 0, nil
+	}
+	return totals.NetCents / totals.Payslips, nil
+}
+
+// CalculateEmployeeYearToDate sums an employee's stored payslips for a year.
+func CalculateEmployeeYearToDate(ctx context.Context, db *sql.DB, employeeID string, year int) (int64, error) {
+	const q = `SELECT COALESCE(sum(net_cents), 0) FROM payslip
+	           WHERE employee_id = $1 AND extract(year from period_from) = $2`
+	var n int64
+	err := db.QueryRowContext(ctx, q, employeeID, year).Scan(&n)
+	return n, err
+}

+ 80 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/dto.go

@@ -0,0 +1,80 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/payroll
+// Regenerate with: go run ./tools/fkitgen ./schema/payroll
+
+package payroll
+
+import "time"
+
+// PayslipDTO is the generated wire representation of a payslip row.
+type PayslipDTO struct {
+	ID             string    `json:"id"`
+	CycleID        string    `json:"cycleId"`
+	EmployeeID     string    `json:"employeeId"`
+	Currency       string    `json:"currency"`
+	PeriodFrom     time.Time `json:"periodFrom"`
+	PeriodTo       time.Time `json:"periodTo"`
+	GrossCents     int64     `json:"grossCents"`
+	DeductionCents int64     `json:"deductionCents"`
+	NetCents       int64     `json:"netCents"`
+}
+
+// PayrollCycleDTO is the generated wire representation of a payroll_cycle row.
+type PayrollCycleDTO struct {
+	ID     string    `json:"id"`
+	Start  time.Time `json:"start"`
+	End    time.Time `json:"end"`
+	Status string    `json:"status"`
+}
+
+// PayslipListDTO is the generated list envelope for payslip rows.
+type PayslipListDTO struct {
+	Items      []PayslipDTO `json:"items"`
+	NextCursor string       `json:"nextCursor,omitempty"`
+	Total      int64        `json:"total"`
+}
+
+// PayslipToDTO converts a payslip row to its wire form.
+func PayslipToDTO(r PayslipRow) PayslipDTO {
+	return PayslipDTO{
+		ID:             r.ID,
+		CycleID:        r.CycleID,
+		EmployeeID:     r.EmployeeID,
+		Currency:       r.Currency,
+		PeriodFrom:     r.PeriodFrom,
+		PeriodTo:       r.PeriodTo,
+		GrossCents:     r.GrossCents,
+		DeductionCents: r.DeductionCents,
+		NetCents:       r.NetCents,
+	}
+}
+
+// PayslipFromDTO converts a wire payslip back to a row.
+func PayslipFromDTO(d PayslipDTO) PayslipRow {
+	return PayslipRow{
+		ID:             d.ID,
+		CycleID:        d.CycleID,
+		EmployeeID:     d.EmployeeID,
+		Currency:       d.Currency,
+		PeriodFrom:     d.PeriodFrom,
+		PeriodTo:       d.PeriodTo,
+		GrossCents:     d.GrossCents,
+		DeductionCents: d.DeductionCents,
+		NetCents:       d.NetCents,
+	}
+}
+
+// PayrollCycleToDTO converts a payroll_cycle row to its wire form.
+func PayrollCycleToDTO(r PayrollCycleRow) PayrollCycleDTO {
+	return PayrollCycleDTO{ID: r.ID, Start: r.Start, End: r.End, Status: r.Status}
+}
+
+// PayslipsToListDTO wraps payslip rows in the generated list envelope.
+func PayslipsToListDTO(rows []PayslipRow, total int64) PayslipListDTO {
+	items := make([]PayslipDTO, 0, len(rows))
+	for _, r := range rows {
+		items = append(items, PayslipToDTO(r))
+	}
+	return PayslipListDTO{Items: items, Total: total}
+}

+ 116 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payroll_cycle.go

@@ -0,0 +1,116 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/payroll/payroll_cycle.fkit
+// Regenerate with: go run ./tools/fkitgen ./schema/payroll
+
+package payroll
+
+import (
+	"context"
+	"database/sql"
+	"time"
+)
+
+// PayrollCycleRow is the generated row type for table payroll_cycle.
+type PayrollCycleRow struct {
+	ID           string
+	Start        time.Time
+	End          time.Time
+	Status       string
+	ClosedAt     time.Time
+	ReopenReason string
+	CreatedAt    time.Time
+	UpdatedAt    time.Time
+}
+
+// PayrollCycleCreateRequest is the generated create payload for payroll_cycle.
+type PayrollCycleCreateRequest struct {
+	Start  time.Time `json:"start"`
+	End    time.Time `json:"end"`
+	Status string    `json:"status"`
+}
+
+// PayrollCycleUpdateRequest is the generated update payload for payroll_cycle.
+type PayrollCycleUpdateRequest struct {
+	Status       *string `json:"status,omitempty"`
+	ReopenReason *string `json:"reopenReason,omitempty"`
+}
+
+// CreatePayrollCycle inserts one payroll_cycle row.
+func CreatePayrollCycle(ctx context.Context, db *sql.DB, req PayrollCycleCreateRequest) (PayrollCycleRow, error) {
+	const q = `INSERT INTO payroll_cycle (start_on, end_on, status) VALUES ($1, $2, $3) RETURNING *`
+	return scanPayrollCycle(db.QueryRowContext(ctx, q, req.Start, req.End, req.Status))
+}
+
+// GetPayrollCycle selects one payroll_cycle row by primary key.
+func GetPayrollCycle(ctx context.Context, db *sql.DB, id string) (PayrollCycleRow, error) {
+	const q = `SELECT * FROM payroll_cycle WHERE id = $1`
+	return scanPayrollCycle(db.QueryRowContext(ctx, q, id))
+}
+
+// UpdatePayrollCycle patches one payroll_cycle row.
+func UpdatePayrollCycle(ctx context.Context, db *sql.DB, id string, req PayrollCycleUpdateRequest) (PayrollCycleRow, error) {
+	const q = `UPDATE payroll_cycle SET status = COALESCE($2, status),
+	           reopen_reason = COALESCE($3, reopen_reason), updated_at = now()
+	           WHERE id = $1 RETURNING *`
+	return scanPayrollCycle(db.QueryRowContext(ctx, q, id, req.Status, req.ReopenReason))
+}
+
+// DeletePayrollCycle removes one payroll_cycle row.
+func DeletePayrollCycle(ctx context.Context, db *sql.DB, id string) error {
+	const q = `DELETE FROM payroll_cycle WHERE id = $1`
+	_, err := db.ExecContext(ctx, q, id)
+	return err
+}
+
+// ListPayrollCycles selects every payroll_cycle row.
+func ListPayrollCycles(ctx context.Context, db *sql.DB) ([]PayrollCycleRow, error) {
+	const q = `SELECT * FROM payroll_cycle ORDER BY start_on DESC`
+	rows, err := db.QueryContext(ctx, q)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []PayrollCycleRow
+	for rows.Next() {
+		var r PayrollCycleRow
+		if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
+			&r.CreatedAt, &r.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// ListPayrollCyclesByStatus selects payroll_cycle rows in one status.
+func ListPayrollCyclesByStatus(ctx context.Context, db *sql.DB, status string) ([]PayrollCycleRow, error) {
+	const q = `SELECT * FROM payroll_cycle WHERE status = $1 ORDER BY start_on DESC`
+	rows, err := db.QueryContext(ctx, q, status)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []PayrollCycleRow
+	for rows.Next() {
+		var r PayrollCycleRow
+		if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
+			&r.CreatedAt, &r.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// BuildPayrollCycle maps a create request onto a row.
+func BuildPayrollCycle(req PayrollCycleCreateRequest) PayrollCycleRow {
+	return PayrollCycleRow{Start: req.Start, End: req.End, Status: req.Status}
+}
+
+func scanPayrollCycle(row *sql.Row) (PayrollCycleRow, error) {
+	var r PayrollCycleRow
+	err := row.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason,
+		&r.CreatedAt, &r.UpdatedAt)
+	return r, err
+}

+ 129 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payslip.go

@@ -0,0 +1,129 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/payroll/payslip.fkit
+// Regenerate with: go run ./tools/fkitgen ./schema/payroll
+
+package payroll
+
+import (
+	"context"
+	"database/sql"
+	"time"
+)
+
+// PayslipRow is the generated row type for table payslip.
+type PayslipRow struct {
+	ID             string
+	CycleID        string
+	EmployeeID     string
+	Currency       string
+	PeriodFrom     time.Time
+	PeriodTo       time.Time
+	GrossCents     int64
+	DeductionCents int64
+	NetCents       int64
+	Underwater     bool
+	RunAt          time.Time
+	RunReason      string
+	CreatedAt      time.Time
+	UpdatedAt      time.Time
+}
+
+// PayslipCreateRequest is the generated create payload for table payslip.
+type PayslipCreateRequest struct {
+	CycleID        string `json:"cycleId"`
+	EmployeeID     string `json:"employeeId"`
+	Currency       string `json:"currency"`
+	GrossCents     int64  `json:"grossCents"`
+	DeductionCents int64  `json:"deductionCents"`
+	NetCents       int64  `json:"netCents"`
+}
+
+// PayslipUpdateRequest is the generated update payload for table payslip.
+type PayslipUpdateRequest struct {
+	GrossCents     *int64  `json:"grossCents,omitempty"`
+	DeductionCents *int64  `json:"deductionCents,omitempty"`
+	NetCents       *int64  `json:"netCents,omitempty"`
+	RunReason      *string `json:"runReason,omitempty"`
+}
+
+// CreatePayslip inserts one payslip row.
+func CreatePayslip(ctx context.Context, db *sql.DB, req PayslipCreateRequest) (PayslipRow, error) {
+	const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents)
+	           VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
+	row := db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Currency, req.GrossCents, req.DeductionCents, req.NetCents)
+	return scanPayslip(row)
+}
+
+// GetPayslip selects one payslip row by primary key.
+func GetPayslip(ctx context.Context, db *sql.DB, id string) (PayslipRow, error) {
+	const q = `SELECT * FROM payslip WHERE id = $1`
+	return scanPayslip(db.QueryRowContext(ctx, q, id))
+}
+
+// UpdatePayslip patches one payslip row.
+func UpdatePayslip(ctx context.Context, db *sql.DB, id string, req PayslipUpdateRequest) (PayslipRow, error) {
+	const q = `UPDATE payslip SET gross_cents = COALESCE($2, gross_cents),
+	           deduction_cents = COALESCE($3, deduction_cents),
+	           net_cents = COALESCE($4, net_cents),
+	           run_reason = COALESCE($5, run_reason),
+	           updated_at = now() WHERE id = $1 RETURNING *`
+	return scanPayslip(db.QueryRowContext(ctx, q, id, req.GrossCents, req.DeductionCents, req.NetCents, req.RunReason))
+}
+
+// DeletePayslip removes one payslip row.
+func DeletePayslip(ctx context.Context, db *sql.DB, id string) error {
+	const q = `DELETE FROM payslip WHERE id = $1`
+	_, err := db.ExecContext(ctx, q, id)
+	return err
+}
+
+// ListPayslipsByCycle selects every payslip row for a cycle.
+func ListPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]PayslipRow, error) {
+	const q = `SELECT * FROM payslip WHERE cycle_id = $1 ORDER BY employee_id`
+	rows, err := db.QueryContext(ctx, q, cycleID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []PayslipRow
+	for rows.Next() {
+		var r PayslipRow
+		if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo,
+			&r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason,
+			&r.CreatedAt, &r.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+// CountPayslipsByCycle counts payslip rows for a cycle.
+func CountPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) (int64, error) {
+	const q = `SELECT count(*) FROM payslip WHERE cycle_id = $1`
+	var n int64
+	err := db.QueryRowContext(ctx, q, cycleID).Scan(&n)
+	return n, err
+}
+
+// BuildPayslip maps a create request onto a row. Field copy only — the
+// generator has no knowledge of pay rules.
+func BuildPayslip(req PayslipCreateRequest) PayslipRow {
+	return PayslipRow{
+		CycleID:        req.CycleID,
+		EmployeeID:     req.EmployeeID,
+		Currency:       req.Currency,
+		GrossCents:     req.GrossCents,
+		DeductionCents: req.DeductionCents,
+		NetCents:       req.NetCents,
+	}
+}
+
+func scanPayslip(row *sql.Row) (PayslipRow, error) {
+	var r PayslipRow
+	err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo,
+		&r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason,
+		&r.CreatedAt, &r.UpdatedAt)
+	return r, err
+}

+ 95 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/store.go

@@ -0,0 +1,95 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/payroll
+// Regenerate with: go run ./tools/fkitgen ./schema/payroll
+
+package payroll
+
+import (
+	"context"
+	"database/sql"
+)
+
+// Store is the generated repository over every payroll table.
+type Store struct {
+	db *sql.DB
+}
+
+// NewStore returns a generated store bound to db.
+func NewStore(db *sql.DB) *Store { return &Store{db: db} }
+
+// Upsert writes one payslip row, keyed by (cycle_id, employee_id).
+func (s *Store) Upsert(ctx context.Context, row PayslipRow) (PayslipRow, error) {
+	const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents)
+	           VALUES ($1, $2, $3, $4, $5, $6)
+	           ON CONFLICT (cycle_id, employee_id) DO UPDATE SET
+	             gross_cents = EXCLUDED.gross_cents,
+	             deduction_cents = EXCLUDED.deduction_cents,
+	             net_cents = EXCLUDED.net_cents,
+	             updated_at = now()
+	           RETURNING *`
+	return scanPayslip(s.db.QueryRowContext(ctx, q, row.CycleID, row.EmployeeID, row.Currency,
+		row.GrossCents, row.DeductionCents, row.NetCents))
+}
+
+// UpsertPayrollCycle writes one payroll_cycle row, keyed by id.
+func (s *Store) UpsertPayrollCycle(ctx context.Context, row PayrollCycleRow) (PayrollCycleRow, error) {
+	const q = `INSERT INTO payroll_cycle (id, start_on, end_on, status)
+	           VALUES ($1, $2, $3, $4)
+	           ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, updated_at = now()
+	           RETURNING *`
+	return scanPayrollCycle(s.db.QueryRowContext(ctx, q, row.ID, row.Start, row.End, row.Status))
+}
+
+// CreatePayslip inserts one payslip row through the store.
+func (s *Store) CreatePayslip(ctx context.Context, req PayslipCreateRequest) (PayslipRow, error) {
+	return CreatePayslip(ctx, s.db, req)
+}
+
+// GetPayslip reads one payslip row through the store.
+func (s *Store) GetPayslip(ctx context.Context, id string) (PayslipRow, error) {
+	return GetPayslip(ctx, s.db, id)
+}
+
+// UpdatePayslip patches one payslip row through the store.
+func (s *Store) UpdatePayslip(ctx context.Context, id string, req PayslipUpdateRequest) (PayslipRow, error) {
+	return UpdatePayslip(ctx, s.db, id, req)
+}
+
+// DeletePayslip removes one payslip row through the store.
+func (s *Store) DeletePayslip(ctx context.Context, id string) error {
+	return DeletePayslip(ctx, s.db, id)
+}
+
+// ListPayslipsByCycle lists payslip rows for a cycle through the store.
+func (s *Store) ListPayslipsByCycle(ctx context.Context, cycleID string) ([]PayslipRow, error) {
+	return ListPayslipsByCycle(ctx, s.db, cycleID)
+}
+
+// CreatePayrollCycle inserts one payroll_cycle row through the store.
+func (s *Store) CreatePayrollCycle(ctx context.Context, req PayrollCycleCreateRequest) (PayrollCycleRow, error) {
+	return CreatePayrollCycle(ctx, s.db, req)
+}
+
+// GetPayrollCycle reads one payroll_cycle row through the store.
+func (s *Store) GetPayrollCycle(ctx context.Context, id string) (PayrollCycleRow, error) {
+	return GetPayrollCycle(ctx, s.db, id)
+}
+
+// ListPayrollCycles lists payroll_cycle rows through the store.
+func (s *Store) ListPayrollCycles(ctx context.Context) ([]PayrollCycleRow, error) {
+	return ListPayrollCycles(ctx, s.db)
+}
+
+// Tx runs fn inside a transaction.
+func (s *Store) Tx(ctx context.Context, fn func(*Store) error) error {
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	if err := fn(s); err != nil {
+		_ = tx.Rollback()
+		return err
+	}
+	return tx.Commit()
+}

+ 76 - 0
__tests__/fixtures/payroll-go/internal/gen/fkit/timesheet/timesheet.go

@@ -0,0 +1,76 @@
+// Code generated by fkit v3.11.0. DO NOT EDIT.
+//
+// Source: schema/timesheet/timesheet.fkit
+// Regenerate with: go run ./tools/fkitgen ./schema/timesheet
+
+package timesheet
+
+import (
+	"context"
+	"database/sql"
+	"time"
+)
+
+// TimesheetRow is the generated row type for table timesheet.
+type TimesheetRow struct {
+	ID         string
+	CycleID    string
+	EmployeeID string
+	Units      int
+	Approved   bool
+	ApprovedAt time.Time
+	CreatedAt  time.Time
+	UpdatedAt  time.Time
+}
+
+// TimesheetCreateRequest is the generated create payload for table timesheet.
+type TimesheetCreateRequest struct {
+	CycleID    string `json:"cycleId"`
+	EmployeeID string `json:"employeeId"`
+	Units      int    `json:"units"`
+}
+
+// CreateTimesheet inserts one timesheet row.
+func CreateTimesheet(ctx context.Context, db *sql.DB, req TimesheetCreateRequest) (TimesheetRow, error) {
+	const q = `INSERT INTO timesheet (cycle_id, employee_id, units) VALUES ($1, $2, $3) RETURNING *`
+	return scanTimesheet(db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Units))
+}
+
+// GetTimesheet selects one timesheet row by primary key.
+func GetTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) {
+	const q = `SELECT * FROM timesheet WHERE id = $1`
+	return scanTimesheet(db.QueryRowContext(ctx, q, id))
+}
+
+// ApproveTimesheet flips the approved column on one timesheet row.
+func ApproveTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) {
+	const q = `UPDATE timesheet SET approved = true, approved_at = now() WHERE id = $1 RETURNING *`
+	return scanTimesheet(db.QueryRowContext(ctx, q, id))
+}
+
+// ListTimesheetsByCycle selects timesheet rows for one cycle.
+func ListTimesheetsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]TimesheetRow, error) {
+	const q = `SELECT * FROM timesheet WHERE cycle_id = $1 ORDER BY employee_id`
+	rows, err := db.QueryContext(ctx, q, cycleID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []TimesheetRow
+	for rows.Next() {
+		var r TimesheetRow
+		if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved,
+			&r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
+
+func scanTimesheet(row *sql.Row) (TimesheetRow, error) {
+	var r TimesheetRow
+	err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved,
+		&r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt)
+	return r, err
+}

+ 139 - 0
__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll.pb.go

@@ -0,0 +1,139 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.34.2
+// 	protoc        v5.27.1
+// source: payroll/v1/payroll.proto
+
+package payrollpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+// RunPayrollCycleRequest is the generated request message.
+type RunPayrollCycleRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
+	DryRun  bool   `protobuf:"varint,2,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"`
+	Reason  string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
+}
+
+func (x *RunPayrollCycleRequest) GetCycleId() string {
+	if x != nil {
+		return x.CycleId
+	}
+	return ""
+}
+
+func (x *RunPayrollCycleRequest) GetDryRun() bool {
+	if x != nil {
+		return x.DryRun
+	}
+	return false
+}
+
+func (x *RunPayrollCycleRequest) GetReason() string {
+	if x != nil {
+		return x.Reason
+	}
+	return ""
+}
+
+func (x *RunPayrollCycleRequest) Reset()         { *x = RunPayrollCycleRequest{} }
+func (x *RunPayrollCycleRequest) String() string { return protoimpl.X.MessageStringOf(x) }
+
+// RunPayrollCycleResponse is the generated response message.
+type RunPayrollCycleResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	CycleId    string     `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
+	Payslips   []*Payslip `protobuf:"bytes,2,rep,name=payslips,proto3" json:"payslips,omitempty"`
+	GrossCents int64      `protobuf:"varint,3,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"`
+	NetCents   int64      `protobuf:"varint,4,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"`
+}
+
+func (x *RunPayrollCycleResponse) GetPayslips() []*Payslip {
+	if x != nil {
+		return x.Payslips
+	}
+	return nil
+}
+
+func (x *RunPayrollCycleResponse) Reset()         { *x = RunPayrollCycleResponse{} }
+func (x *RunPayrollCycleResponse) String() string { return protoimpl.X.MessageStringOf(x) }
+
+// Payslip is the generated payslip message.
+type Payslip struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Id             string                 `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+	CycleId        string                 `protobuf:"bytes,2,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"`
+	EmployeeId     string                 `protobuf:"bytes,3,opt,name=employee_id,json=employeeId,proto3" json:"employee_id,omitempty"`
+	GrossCents     int64                  `protobuf:"varint,4,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"`
+	DeductionCents int64                  `protobuf:"varint,5,opt,name=deduction_cents,json=deductionCents,proto3" json:"deduction_cents,omitempty"`
+	NetCents       int64                  `protobuf:"varint,6,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"`
+	PeriodFrom     *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=period_from,json=periodFrom,proto3" json:"period_from,omitempty"`
+	PeriodTo       *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=period_to,json=periodTo,proto3" json:"period_to,omitempty"`
+}
+
+func (x *Payslip) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+func (x *Payslip) GetNetCents() int64 {
+	if x != nil {
+		return x.NetCents
+	}
+	return 0
+}
+
+func (x *Payslip) Reset()         { *x = Payslip{} }
+func (x *Payslip) String() string { return protoimpl.X.MessageStringOf(x) }
+
+// PayrollCycle is the generated cycle message.
+type PayrollCycle struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Id     string                 `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+	Start  *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=start,proto3" json:"start,omitempty"`
+	End    *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=end,proto3" json:"end,omitempty"`
+	Status string                 `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
+}
+
+func (x *PayrollCycle) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+func (x *PayrollCycle) Reset()         { *x = PayrollCycle{} }
+func (x *PayrollCycle) String() string { return protoimpl.X.MessageStringOf(x) }
+
+var file_payroll_v1_payroll_proto_rawDesc = []byte{
+	0x0a, 0x18, 0x70, 0x61, 0x79, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x61, 0x79,
+	0x72, 0x6f, 0x6c, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x70, 0x61, 0x79, 0x72,
+}
+
+var file_payroll_v1_payroll_proto_goTypes = []any{
+	(*RunPayrollCycleRequest)(nil),
+	(*RunPayrollCycleResponse)(nil),
+	(*Payslip)(nil),
+	(*PayrollCycle)(nil),
+}
+
+var File_payroll_v1_payroll_proto protoreflect.FileDescriptor

+ 104 - 0
__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll_grpc.pb.go

@@ -0,0 +1,104 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.4.0
+// - protoc             v5.27.1
+// source: payroll/v1/payroll.proto
+
+package payrollpb
+
+import (
+	context "context"
+
+	grpc "google.golang.org/grpc"
+)
+
+const (
+	PayrollService_RunPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/RunPayrollCycle"
+	PayrollService_GetPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/GetPayrollCycle"
+	PayrollService_ListPayslips_FullMethodName    = "/payroll.v1.PayrollService/ListPayslips"
+)
+
+// PayrollServiceClient is the generated client API for PayrollService.
+type PayrollServiceClient interface {
+	RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error)
+	GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error)
+	ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error)
+}
+
+type payrollServiceClient struct {
+	cc grpc.ClientConnInterface
+}
+
+// NewPayrollServiceClient returns a generated client.
+func NewPayrollServiceClient(cc grpc.ClientConnInterface) PayrollServiceClient {
+	return &payrollServiceClient{cc}
+}
+
+func (c *payrollServiceClient) RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) {
+	out := new(RunPayrollCycleResponse)
+	err := c.cc.Invoke(ctx, PayrollService_RunPayrollCycle_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *payrollServiceClient) GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error) {
+	out := new(PayrollCycle)
+	err := c.cc.Invoke(ctx, PayrollService_GetPayrollCycle_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *payrollServiceClient) ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) {
+	out := new(RunPayrollCycleResponse)
+	err := c.cc.Invoke(ctx, PayrollService_ListPayslips_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// PayrollServiceServer is the generated server API for PayrollService.
+type PayrollServiceServer interface {
+	RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error)
+	GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error)
+	ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error)
+	mustEmbedUnimplementedPayrollServiceServer()
+}
+
+// UnimplementedPayrollServiceServer must be embedded for forward compatibility.
+type UnimplementedPayrollServiceServer struct{}
+
+func (UnimplementedPayrollServiceServer) RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) {
+	return nil, nil
+}
+
+func (UnimplementedPayrollServiceServer) GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error) {
+	return nil, nil
+}
+
+func (UnimplementedPayrollServiceServer) ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) {
+	return nil, nil
+}
+
+func (UnimplementedPayrollServiceServer) mustEmbedUnimplementedPayrollServiceServer() {}
+
+// RegisterPayrollServiceServer registers the generated service.
+func RegisterPayrollServiceServer(s grpc.ServiceRegistrar, srv PayrollServiceServer) {
+	s.RegisterService(&PayrollService_ServiceDesc, srv)
+}
+
+// PayrollService_ServiceDesc is the generated service descriptor.
+var PayrollService_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "payroll.v1.PayrollService",
+	HandlerType: (*PayrollServiceServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{MethodName: "RunPayrollCycle"},
+		{MethodName: "GetPayrollCycle"},
+		{MethodName: "ListPayslips"},
+	},
+	Metadata: "payroll/v1/payroll.proto",
+}

+ 18 - 0
__tests__/fixtures/payroll-go/internal/platform/clock/clock.go

@@ -0,0 +1,18 @@
+package clock
+
+import "time"
+
+// Clock is the time seam so a payroll run is reproducible in tests.
+type Clock interface {
+	Now() time.Time
+}
+
+// System is the production clock.
+type System struct{}
+
+func (System) Now() time.Time { return time.Now().UTC() }
+
+// Fixed is a frozen clock.
+type Fixed struct{ At time.Time }
+
+func (f Fixed) Now() time.Time { return f.At }

+ 119 - 0
__tests__/fixtures/payroll-go/internal/store/payslipstore/store.go

@@ -0,0 +1,119 @@
+package payslipstore
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	"github.com/example/payroll-svc/internal/domain/payroll"
+)
+
+// Store is the hand-written persistence seam the use-case layer writes through.
+// It is deliberately narrow: the generated fkit store can address every table,
+// this one only exposes the operations a payroll cycle needs.
+type Store struct {
+	mu        sync.RWMutex
+	payslips  map[string]payroll.Payslip
+	cycles    map[string]payroll.Cycle
+	employees map[string][]payroll.Employee
+	sheets    map[string]payroll.Timesheet
+}
+
+func New() *Store {
+	return &Store{
+		payslips:  map[string]payroll.Payslip{},
+		cycles:    map[string]payroll.Cycle{},
+		employees: map[string][]payroll.Employee{},
+		sheets:    map[string]payroll.Timesheet{},
+	}
+}
+
+func key(cycleID, employeeID string) string { return cycleID + "/" + employeeID }
+
+// Upsert writes a payslip, replacing any prior slip for the same
+// (cycle, employee). A re-run of a cycle must not duplicate rows, so this is
+// an upsert rather than an insert.
+func (s *Store) Upsert(ctx context.Context, slip payroll.Payslip) error {
+	if err := ctx.Err(); err != nil {
+		return err
+	}
+	if slip.CycleID == "" || slip.EmployeeID == "" {
+		return fmt.Errorf("payslip missing cycle or employee id")
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.payslips[key(slip.CycleID, slip.EmployeeID)] = slip
+	return nil
+}
+
+// ListByCycle returns every payslip a cycle produced.
+func (s *Store) ListByCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	out := make([]payroll.Payslip, 0, len(s.payslips))
+	for _, slip := range s.payslips {
+		if slip.CycleID == cycleID {
+			out = append(out, slip)
+		}
+	}
+	return out, nil
+}
+
+func (s *Store) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
+	if err := ctx.Err(); err != nil {
+		return payroll.Cycle{}, err
+	}
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	cycle, ok := s.cycles[cycleID]
+	if !ok {
+		return payroll.Cycle{}, fmt.Errorf("cycle %s not found", cycleID)
+	}
+	return cycle, nil
+}
+
+func (s *Store) SaveCycle(ctx context.Context, cycle payroll.Cycle) error {
+	if err := ctx.Err(); err != nil {
+		return err
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.cycles[cycle.ID] = cycle
+	return nil
+}
+
+func (s *Store) EmployeesForCycle(ctx context.Context, cycleID string) ([]payroll.Employee, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.employees[cycleID], nil
+}
+
+func (s *Store) Timesheet(ctx context.Context, cycleID, employeeID string) (payroll.Timesheet, error) {
+	if err := ctx.Err(); err != nil {
+		return payroll.Timesheet{}, err
+	}
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	ts, ok := s.sheets[key(cycleID, employeeID)]
+	if !ok {
+		return payroll.Timesheet{}, fmt.Errorf("no timesheet for %s in %s", employeeID, cycleID)
+	}
+	return ts, nil
+}
+
+// Seed loads fixture data; the real service reads from Postgres.
+func (s *Store) Seed(cycle payroll.Cycle, employees []payroll.Employee, sheets []payroll.Timesheet) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.cycles[cycle.ID] = cycle
+	s.employees[cycle.ID] = employees
+	for _, ts := range sheets {
+		s.sheets[key(ts.CycleID, ts.EmployeeID)] = ts
+	}
+}

+ 96 - 0
__tests__/fixtures/payroll-go/internal/transport/httpapi/payroll_handler.go

@@ -0,0 +1,96 @@
+package httpapi
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+
+	"github.com/example/payroll-svc/internal/usecase/payroll"
+)
+
+// PayrollHandler is the HTTP entry point into the payroll use-case layer.
+type PayrollHandler struct {
+	svc *payroll.Service
+}
+
+func NewPayrollHandler(svc *payroll.Service) *PayrollHandler {
+	return &PayrollHandler{svc: svc}
+}
+
+type runCycleRequest struct {
+	DryRun bool   `json:"dryRun"`
+	Reason string `json:"reason"`
+}
+
+type runCycleResponse struct {
+	CycleID    string `json:"cycleId"`
+	Payslips   int    `json:"payslips"`
+	GrossCents int64  `json:"grossCents"`
+	NetCents   int64  `json:"netCents"`
+}
+
+// RunCycle kicks off a payroll cycle: it hands the cycle id to the use-case
+// layer, which builds and persists a payslip per active employee.
+func (h *PayrollHandler) RunCycle(w http.ResponseWriter, r *http.Request) {
+	cycleID := r.PathValue("cycleID")
+	if cycleID == "" {
+		httpError(w, http.StatusBadRequest, "cycleID is required")
+		return
+	}
+
+	var req runCycleRequest
+	if r.ContentLength > 0 {
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			httpError(w, http.StatusBadRequest, "malformed body")
+			return
+		}
+	}
+
+	result, err := h.svc.RunCycle(r.Context(), cycleID, payroll.RunOptions{
+		DryRun: req.DryRun,
+		Reason: req.Reason,
+	})
+	if err != nil {
+		if errors.Is(err, payroll.ErrCycleClosed) {
+			httpError(w, http.StatusConflict, "cycle already closed")
+			return
+		}
+		httpError(w, http.StatusInternalServerError, "run failed")
+		return
+	}
+
+	writeJSON(w, http.StatusOK, runCycleResponse{
+		CycleID:    result.CycleID,
+		Payslips:   len(result.Payslips),
+		GrossCents: result.TotalGrossCents,
+		NetCents:   result.TotalNetCents,
+	})
+}
+
+func (h *PayrollHandler) GetCycle(w http.ResponseWriter, r *http.Request) {
+	cycle, err := h.svc.Cycle(r.Context(), r.PathValue("cycleID"))
+	if err != nil {
+		httpError(w, http.StatusNotFound, "no such cycle")
+		return
+	}
+	writeJSON(w, http.StatusOK, cycle)
+}
+
+func (h *PayrollHandler) ListPayslips(w http.ResponseWriter, r *http.Request) {
+	slips, err := h.svc.PayslipsForCycle(r.Context(), r.PathValue("cycleID"))
+	if err != nil {
+		httpError(w, http.StatusNotFound, "no such cycle")
+		return
+	}
+	writeJSON(w, http.StatusOK, slips)
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(body)
+}
+
+func httpError(w http.ResponseWriter, status int, msg string) {
+	writeJSON(w, status, map[string]string{"error": msg})
+}

+ 19 - 0
__tests__/fixtures/payroll-go/internal/transport/httpapi/router.go

@@ -0,0 +1,19 @@
+package httpapi
+
+import "net/http"
+
+// NewRouter wires the HTTP surface. The payroll cycle endpoint is the only
+// entry point into the hand-written use-case layer.
+func NewRouter(h *PayrollHandler) http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("POST /v1/payroll/cycles/{cycleID}/run", h.RunCycle)
+	mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}", h.GetCycle)
+	mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}/payslips", h.ListPayslips)
+	mux.HandleFunc("GET /healthz", health)
+	return mux
+}
+
+func health(w http.ResponseWriter, _ *http.Request) {
+	w.WriteHeader(http.StatusOK)
+	_, _ = w.Write([]byte("ok"))
+}

+ 227 - 0
__tests__/fixtures/payroll-go/internal/usecase/payroll/cycle.go

@@ -0,0 +1,227 @@
+package payroll
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sort"
+	"time"
+
+	"github.com/example/payroll-svc/internal/domain/payroll"
+	"github.com/example/payroll-svc/internal/platform/clock"
+	"github.com/example/payroll-svc/internal/store/payslipstore"
+)
+
+// ErrCycleClosed is returned when a cycle has already been finalized.
+var ErrCycleClosed = errors.New("payroll cycle is closed")
+
+// ErrNoEmployees is returned when a cycle resolves to an empty roster.
+var ErrNoEmployees = errors.New("payroll cycle has no active employees")
+
+// RunOptions tunes a single run of a payroll cycle.
+type RunOptions struct {
+	// DryRun computes every payslip but persists nothing.
+	DryRun bool
+	// Reason is recorded on the audit trail for re-runs.
+	Reason string
+	// Only, when non-empty, restricts the run to these employee ids.
+	Only []string
+}
+
+// RunResult is the outcome of one payroll cycle run.
+type RunResult struct {
+	CycleID         string
+	Payslips        []payroll.Payslip
+	TotalGrossCents int64
+	TotalNetCents   int64
+	Skipped         []string
+	FinishedAt      time.Time
+}
+
+// Service is the hand-written payroll use-case layer. It owns the order of
+// operations for a cycle: resolve the roster, build a payslip per employee,
+// then persist. The generated CRUD layer under internal/gen has no opinion
+// about any of that — it can only read and write single rows.
+type Service struct {
+	store *payslipstore.Store
+	clock clock.Clock
+}
+
+func NewService(store *payslipstore.Store, c clock.Clock) *Service {
+	return &Service{store: store, clock: c}
+}
+
+// RunCycle is the public entry point used by the HTTP handler. It loads the
+// cycle, guards its state, and delegates the actual work to runPayrollCycleAll.
+func (s *Service) RunCycle(ctx context.Context, cycleID string, opts RunOptions) (RunResult, error) {
+	cycle, err := s.loadCycle(ctx, cycleID)
+	if err != nil {
+		return RunResult{}, err
+	}
+	if cycle.Status == payroll.CycleClosed {
+		return RunResult{}, ErrCycleClosed
+	}
+
+	roster, err := s.rosterFor(ctx, cycle, opts)
+	if err != nil {
+		return RunResult{}, err
+	}
+	if len(roster) == 0 {
+		return RunResult{}, ErrNoEmployees
+	}
+
+	return s.runPayrollCycleAll(ctx, cycle, roster, opts)
+}
+
+// runPayrollCycleAll is the heart of the cycle: for every employee on the
+// roster it builds a payslip from that employee's contract and timesheet,
+// then upserts the result. Ordering matters — a payslip is only persisted
+// after every earning, deduction and tax line has been resolved, so a
+// partially-computed slip can never reach the store.
+func (s *Service) runPayrollCycleAll(
+	ctx context.Context,
+	cycle payroll.Cycle,
+	roster []payroll.Employee,
+	opts RunOptions,
+) (RunResult, error) {
+	result := RunResult{CycleID: cycle.ID}
+	now := s.clock.Now()
+
+	for _, employee := range roster {
+		if err := ctx.Err(); err != nil {
+			return result, err
+		}
+
+		timesheet, err := s.timesheetFor(ctx, cycle, employee)
+		if err != nil {
+			result.Skipped = append(result.Skipped, employee.ID)
+			continue
+		}
+
+		slip, err := s.BuildPayslip(ctx, cycle, employee, timesheet)
+		if err != nil {
+			return result, fmt.Errorf("build payslip for %s: %w", employee.ID, err)
+		}
+
+		slip.RunAt = now
+		slip.RunReason = opts.Reason
+
+		if !opts.DryRun {
+			if err := s.store.Upsert(ctx, slip); err != nil {
+				return result, fmt.Errorf("persist payslip for %s: %w", employee.ID, err)
+			}
+		}
+
+		result.Payslips = append(result.Payslips, slip)
+		result.TotalGrossCents += slip.GrossCents
+		result.TotalNetCents += slip.NetCents
+	}
+
+	if !opts.DryRun {
+		if err := s.closeCycle(ctx, cycle, now); err != nil {
+			return result, err
+		}
+	}
+
+	sort.Slice(result.Payslips, func(i, j int) bool {
+		return result.Payslips[i].EmployeeID < result.Payslips[j].EmployeeID
+	})
+	result.FinishedAt = now
+	return result, nil
+}
+
+// rosterFor resolves which employees this cycle pays. An employee joins the
+// roster when their contract overlaps the cycle window and they are not on
+// unpaid leave for the whole period.
+func (s *Service) rosterFor(ctx context.Context, cycle payroll.Cycle, opts RunOptions) ([]payroll.Employee, error) {
+	all, err := s.store.EmployeesForCycle(ctx, cycle.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	only := map[string]bool{}
+	for _, id := range opts.Only {
+		only[id] = true
+	}
+
+	roster := make([]payroll.Employee, 0, len(all))
+	for _, e := range all {
+		if len(only) > 0 && !only[e.ID] {
+			continue
+		}
+		if !e.Contract.OverlapsWindow(cycle.Start, cycle.End) {
+			continue
+		}
+		if e.UnpaidLeaveCoversWindow(cycle.Start, cycle.End) {
+			continue
+		}
+		roster = append(roster, e)
+	}
+
+	sort.Slice(roster, func(i, j int) bool { return roster[i].ID < roster[j].ID })
+	return roster, nil
+}
+
+func (s *Service) timesheetFor(ctx context.Context, cycle payroll.Cycle, e payroll.Employee) (payroll.Timesheet, error) {
+	ts, err := s.store.Timesheet(ctx, cycle.ID, e.ID)
+	if err != nil {
+		return payroll.Timesheet{}, err
+	}
+	if ts.Approved {
+		return ts, nil
+	}
+	if e.Contract.Kind == payroll.ContractSalaried {
+		// Salaried staff are paid the contractual period regardless of an
+		// unapproved timesheet; hourly staff are skipped until approval.
+		return payroll.Timesheet{
+			CycleID:    cycle.ID,
+			EmployeeID: e.ID,
+			Approved:   true,
+			Units:      e.Contract.PeriodUnits(cycle.Start, cycle.End),
+		}, nil
+	}
+	return payroll.Timesheet{}, fmt.Errorf("timesheet for %s not approved", e.ID)
+}
+
+func (s *Service) loadCycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
+	if cycleID == "" {
+		return payroll.Cycle{}, errors.New("empty cycle id")
+	}
+	return s.store.Cycle(ctx, cycleID)
+}
+
+func (s *Service) closeCycle(ctx context.Context, cycle payroll.Cycle, at time.Time) error {
+	cycle.Status = payroll.CycleClosed
+	cycle.ClosedAt = at
+	return s.store.SaveCycle(ctx, cycle)
+}
+
+// Cycle exposes a cycle for the read endpoints.
+func (s *Service) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) {
+	return s.loadCycle(ctx, cycleID)
+}
+
+// PayslipsForCycle lists the payslips a completed cycle produced.
+func (s *Service) PayslipsForCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) {
+	slips, err := s.store.ListByCycle(ctx, cycleID)
+	if err != nil {
+		return nil, err
+	}
+	sort.Slice(slips, func(i, j int) bool { return slips[i].EmployeeID < slips[j].EmployeeID })
+	return slips, nil
+}
+
+// Reopen unwinds a closed cycle so it can be re-run after a correction.
+func (s *Service) Reopen(ctx context.Context, cycleID string, reason string) error {
+	cycle, err := s.loadCycle(ctx, cycleID)
+	if err != nil {
+		return err
+	}
+	if cycle.Status != payroll.CycleClosed {
+		return nil
+	}
+	cycle.Status = payroll.CycleOpen
+	cycle.ReopenReason = reason
+	cycle.ClosedAt = time.Time{}
+	return s.store.SaveCycle(ctx, cycle)
+}

+ 150 - 0
__tests__/fixtures/payroll-go/internal/usecase/payroll/payslip_builder.go

@@ -0,0 +1,150 @@
+package payroll
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/example/payroll-svc/internal/domain/payroll"
+)
+
+// BuildPayslip turns one employee's contract and timesheet into a complete
+// payslip for the cycle: base pay, overtime, allowances, then deductions and
+// tax, in that order. Every amount is in integer cents; nothing here rounds
+// until the final net, so a cent never disappears between two lines.
+//
+// This is the calculation the generated CRUD layer does NOT do — fkit's
+// BuildPayslip only copies fields between a DTO and a row.
+func (s *Service) BuildPayslip(
+	ctx context.Context,
+	cycle payroll.Cycle,
+	employee payroll.Employee,
+	timesheet payroll.Timesheet,
+) (payroll.Payslip, error) {
+	if err := ctx.Err(); err != nil {
+		return payroll.Payslip{}, err
+	}
+	if timesheet.EmployeeID != "" && timesheet.EmployeeID != employee.ID {
+		return payroll.Payslip{}, fmt.Errorf("timesheet/employee mismatch: %s vs %s", timesheet.EmployeeID, employee.ID)
+	}
+
+	slip := payroll.Payslip{
+		CycleID:    cycle.ID,
+		EmployeeID: employee.ID,
+		Currency:   employee.Contract.Currency,
+		PeriodFrom: cycle.Start,
+		PeriodTo:   cycle.End,
+	}
+
+	base := s.basePayCents(employee, cycle, timesheet)
+	slip.Lines = append(slip.Lines, payroll.Line{
+		Code: "BASE", Kind: payroll.LineEarning, AmountCents: base,
+	})
+
+	if overtime := s.overtimeCents(employee, timesheet); overtime > 0 {
+		slip.Lines = append(slip.Lines, payroll.Line{
+			Code: "OT", Kind: payroll.LineEarning, AmountCents: overtime,
+		})
+	}
+
+	for _, allowance := range employee.Contract.Allowances {
+		amount := prorateAllowance(allowance, cycle, employee)
+		if amount == 0 {
+			continue
+		}
+		slip.Lines = append(slip.Lines, payroll.Line{
+			Code: allowance.Code, Kind: payroll.LineEarning, AmountCents: amount,
+		})
+	}
+
+	slip.GrossCents = sumKind(slip.Lines, payroll.LineEarning)
+
+	for _, d := range employee.Deductions {
+		amount := d.AmountFor(slip.GrossCents)
+		if amount == 0 {
+			continue
+		}
+		slip.Lines = append(slip.Lines, payroll.Line{
+			Code: d.Code, Kind: payroll.LineDeduction, AmountCents: amount,
+		})
+	}
+
+	tax, err := s.taxCents(employee, slip.GrossCents)
+	if err != nil {
+		return payroll.Payslip{}, fmt.Errorf("tax for %s: %w", employee.ID, err)
+	}
+	slip.Lines = append(slip.Lines, payroll.Line{
+		Code: "TAX", Kind: payroll.LineDeduction, AmountCents: tax,
+	})
+
+	slip.DeductionCents = sumKind(slip.Lines, payroll.LineDeduction)
+	slip.NetCents = slip.GrossCents - slip.DeductionCents
+	if slip.NetCents < 0 {
+		slip.NetCents = 0
+		slip.Underwater = true
+	}
+
+	return slip, nil
+}
+
+// basePayCents is the contractual pay for the period: salaried staff get the
+// period rate prorated across their contract window, hourly staff get rate ×
+// approved units.
+func (s *Service) basePayCents(e payroll.Employee, cycle payroll.Cycle, ts payroll.Timesheet) int64 {
+	switch e.Contract.Kind {
+	case payroll.ContractSalaried:
+		full := e.Contract.PeriodRateCents
+		return prorateSalary(full, e.Contract, cycle)
+	case payroll.ContractHourly:
+		return e.Contract.RateCents * int64(ts.Units)
+	default:
+		return 0
+	}
+}
+
+// overtimeCents pays approved units above the contractual threshold at the
+// contract's overtime multiplier.
+func (s *Service) overtimeCents(e payroll.Employee, ts payroll.Timesheet) int64 {
+	if e.Contract.Kind != payroll.ContractHourly {
+		return 0
+	}
+	threshold := e.Contract.OvertimeThresholdUnits
+	if threshold <= 0 || ts.Units <= threshold {
+		return 0
+	}
+	extra := int64(ts.Units - threshold)
+	return int64(float64(e.Contract.RateCents) * e.Contract.OvertimeMultiplier * float64(extra))
+}
+
+// taxCents applies the employee's tax band schedule to the gross.
+func (s *Service) taxCents(e payroll.Employee, gross int64) (int64, error) {
+	if len(e.TaxBands) == 0 {
+		return 0, nil
+	}
+	var tax int64
+	remaining := gross
+	for _, band := range e.TaxBands {
+		if remaining <= 0 {
+			break
+		}
+		if band.RateBasisPoints < 0 || band.RateBasisPoints > 10000 {
+			return 0, fmt.Errorf("invalid band rate %d", band.RateBasisPoints)
+		}
+		slice := remaining
+		if band.UpToCents > 0 && slice > band.UpToCents {
+			slice = band.UpToCents
+		}
+		tax += slice * int64(band.RateBasisPoints) / 10000
+		remaining -= slice
+	}
+	return tax, nil
+}
+
+func sumKind(lines []payroll.Line, kind payroll.LineKind) int64 {
+	var total int64
+	for _, l := range lines {
+		if l.Kind == kind {
+			total += l.AmountCents
+		}
+	}
+	return total
+}

+ 53 - 0
__tests__/fixtures/payroll-go/internal/usecase/payroll/prorate.go

@@ -0,0 +1,53 @@
+package payroll
+
+import (
+	"time"
+
+	"github.com/example/payroll-svc/internal/domain/payroll"
+)
+
+// prorateSalary scales a full period rate down when the contract covers only
+// part of the cycle window (a mid-period joiner or leaver).
+func prorateSalary(fullCents int64, contract payroll.Contract, cycle payroll.Cycle) int64 {
+	window := calendarDays(cycle.Start, cycle.End)
+	if window <= 0 {
+		return 0
+	}
+	covered := calendarDays(laterOf(cycle.Start, contract.StartsOn), earlierOf(cycle.End, contract.EndsOn))
+	if covered >= window {
+		return fullCents
+	}
+	if covered <= 0 {
+		return 0
+	}
+	return fullCents * int64(covered) / int64(window)
+}
+
+// prorateAllowance applies the same window rule to a recurring allowance.
+func prorateAllowance(a payroll.Allowance, cycle payroll.Cycle, e payroll.Employee) int64 {
+	if !a.Prorated {
+		return a.AmountCents
+	}
+	return prorateSalary(a.AmountCents, e.Contract, cycle)
+}
+
+func calendarDays(from, to time.Time) int {
+	if to.Before(from) {
+		return 0
+	}
+	return int(to.Sub(from).Hours()/24) + 1
+}
+
+func laterOf(a, b time.Time) time.Time {
+	if b.IsZero() || a.After(b) {
+		return a
+	}
+	return b
+}
+
+func earlierOf(a, b time.Time) time.Time {
+	if b.IsZero() || a.Before(b) {
+		return a
+	}
+	return b
+}

+ 78 - 0
docs/design/explore-budget-allocation.md

@@ -92,6 +92,84 @@ This is the gap the rest of the epic closes: relevance-proportional allocation w
 relative cliff (CG-12), on top of scoring that stops rewarding incidental name collisions
 relative cliff (CG-12), on top of scoring that stops rewarding incidental name collisions
 (CG-10).
 (CG-10).
 
 
+## The regression fixtures (CG-6)
+
+Two fixtures pin the failure mode so it can never silently return. Both **fail today** —
+that is what they are for. They become the pass gate for CG-10 + CG-12.
+
+They are declared in `scripts/agent-eval/allocation-fixtures.json` and run by
+`scripts/agent-eval/probe-allocation.mjs`, which drives the CG-4 diagnostic through a JSONL
+sidecar (so it measures the shipping allocator, not a re-derivation), groups the rendered
+files into `answer` vs `incidental`, and checks declared share thresholds. Needs a current
+`npm run build`; exits 1 while any assertion fails.
+
+```bash
+node scripts/agent-eval/probe-allocation.mjs                # both
+node scripts/agent-eval/probe-allocation.mjs payroll-go     # one
+node scripts/agent-eval/probe-allocation.mjs --json         # machine-readable
+```
+
+### 1. `payroll-go` — the reporter's shape
+
+`__tests__/fixtures/payroll-go/` is a synthetic Go service: generated FKIT CRUD beside a
+hand-written payroll use-case, entered from an HTTP route. Full description in that
+directory's README. The essentials:
+
+- Generated files with **ordinary names** carrying `// Code generated ... DO NOT EDIT.` —
+  invisible to path-only detection, which is what makes this a #1500 fixture rather than a
+  `.pb.go` one — beside `payrollpb/*.pb.go` covering the path-detectable channel.
+- Deliberate collisions: `BuildPayslip`, `Upsert` and `Store` each exist twice, generated
+  and hand-written, and the generated layer name-collides on every query term.
+- `cycle.go` (227 lines) sits above the whole-file window so it clips; the generated files
+  sit below it so they ship whole.
+
+Query — an architecture question naming none of the answering symbols: *"how does payroll
+cycle create and calculate payslips?"*
+
+| | allocated | delivered |
+|---|---|---|
+| hand-written | 48.4% | **25.6%** (all of it domain types) |
+| generated CRUD | 39.9% | **57.4%** |
+
+`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers **zero**
+— the 19,500 hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never
+renders. So `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real `Upsert`
+never reach the agent, and every byte that did arrive describes either CRUD or types.
+
+This fixture is hermetic: the probe copies the tree to a temp dir and re-indexes per run,
+so two runs on one build are byte-identical (verified). `__tests__/explore-allocation-1500.test.ts`
+runs the same assertions in vitest — the fixture-shape half green, the allocation half as
+`it.fails` so the suite stays green while the bug is open and goes **red when it is fixed**.
+
+**Finding, deliberately left unfixed:** `runPayrollCycleAll` calls `s.store.Upsert` on a
+`*payslipstore.Store`, but the graph resolves that edge to the **generated**
+`internal/gen/fkit/payroll/store.go` `Store.Upsert`. Same-name method resolution across two
+packages that both define `Store.Upsert` picks the wrong receiver. It is upstream of the
+allocation bug — a wrong edge pulls the generated store into the subgraph and inflates its
+score — so it belongs with CG-10's scoring work, not with the fixture.
+
+### 2. `self-query` — the same bug with no generated code in sight
+
+The baseline above, promoted to a fixture: this repo, *"how does explore allocate its output
+budget across files"*. `scripts/agent-eval/*.mjs` mention `explore` and `BUDGET`
+incidentally — they are eval harnesses, not the allocator — and they are small enough to
+ship whole, while `src/mcp/tools.ts` is large enough to be clipped.
+
+At 493 indexed files (small tier): the script corpus takes **71.8%** of the delivered
+envelope (79.4% allocated) against `tools.ts`'s **18.5%**, despite `tools.ts` scoring 46 vs
+10, carrying 2.3× the graph mass and 3× the distinct term hits.
+
+This fixture reads the **live** index of this repo, so unlike `payroll-go` its exact numbers
+move as the repo changes. Its assertions are relative for that reason (answer group vs
+incidental group, largest delivered file), never fixed percentages. Two things to know:
+
+- The `<500`-file tier boundary is close. This repo indexes 493 files including the new
+  fixture; crossing 500 flips `maxOutputChars` 18,000 → 24,000, `maxFiles` 5 → 8 and
+  `maxCharsPerFile` 3,800 → 6,500, which moves every number in the table above. Re-baseline
+  after the crossing rather than treating the drift as a regression.
+- Adding the `payroll-go` fixture itself moved the count 472 → 493. Its Go files match none
+  of this query's terms, so they change the tier arithmetic and nothing else.
+
 ### Reproducing
 ### Reproducing
 
 
 The query explores this repo, so **uncommitted edits to `src/mcp/tools.ts` change the
 The query explores this repo, so **uncommitted edits to `src/mcp/tools.ts` change the

+ 112 - 0
scripts/agent-eval/allocation-fixtures.json

@@ -0,0 +1,112 @@
+{
+  "$comment": [
+    "Regression fixtures for GitHub issue #1500 / epic CG-1 — relevance-proportional",
+    "explore budget allocation. Run them with `node scripts/agent-eval/probe-allocation.mjs`",
+    "against a built dist/. BOTH FIXTURES FAIL TODAY: that is the point — they document",
+    "the bug and become the pass gate for the allocation change (CG-10 + CG-12).",
+    "",
+    "`groups` partitions the files explore rendered into `answer` (what the query is",
+    "actually about) and `incidental` (what wins the envelope today on name collisions).",
+    "Assertions are on the DELIVERED envelope unless suffixed `Allocated`; delivered is",
+    "what the agent got, allocated is what the render loop chose before the hard ceiling.",
+    "Shares are fractions of the whole response, meta-text included, so they never sum to 1."
+  ],
+  "fixtures": [
+    {
+      "id": "payroll-go",
+      "title": "#1500 — generated Go CRUD beside a hand-written payroll workflow",
+      "kind": "fixture",
+      "path": "__tests__/fixtures/payroll-go",
+      "query": "how does payroll cycle create and calculate payslips?",
+      "rationale": [
+        "The reporter's repo shape: a Go service whose generated FKIT CRUD layer sits",
+        "beside the hand-written use-case that does the real work. The query deliberately",
+        "does NOT name runPayrollCycleAll / BuildPayslip / Upsert — an architecture",
+        "question phrased the way a newcomer would phrase it. The generated layer",
+        "name-collides on every query term (CreatePayslip, PayrollCycleCreateRequest,",
+        "CalculatePayrollCycleTotals, a second BuildPayslip, a second Upsert), so a",
+        "scorer that rewards incidental name matches surfaces the CRUD path.",
+        "Half the generated files carry ORDINARY names and are only detectable by their",
+        "`// Code generated ... DO NOT EDIT.` header (CG-5), which is what makes this",
+        "the #1500 case rather than a .pb.go case."
+      ],
+      "groups": {
+        "answer": [
+          "internal/usecase/**",
+          "internal/store/**",
+          "internal/transport/**",
+          "internal/domain/**",
+          "cmd/**"
+        ],
+        "incidental": ["internal/gen/**"]
+      },
+      "assert": {
+        "answerShareAtLeast": 0.55,
+        "incidentalShareAtMost": 0.25,
+        "topFileGroup": "answer",
+        "mustDeliverBytes": [
+          "internal/usecase/payroll/cycle.go",
+          "internal/usecase/payroll/payslip_builder.go"
+        ],
+        "$mustContainComment": "Needles are chosen to match the HAND-WRITTEN chain only — a bare `BuildPayslip`/`Upsert` also matches the generated collisions, which is the whole point of the fixture.",
+        "mustContain": [
+          "runPayrollCycleAll",
+          "func (s *Service) BuildPayslip",
+          "s.store.Upsert(ctx, slip)"
+        ]
+      },
+      "baseline": {
+        "measuredOn": "2026-08-03",
+        "note": "19 files → very-tiny tier (13,000 budget); 23,020 chars allocated against it, cut to 16,011 by the 19,500 hard ceiling.",
+        "delivered": {
+          "internal/gen/fkit/payroll/payslip.go": 0.307,
+          "internal/gen/fkit/payroll/payroll_cycle.go": 0.266,
+          "internal/domain/payroll/payslip.go": 0.256,
+          "internal/usecase/payroll/cycle.go": 0.0
+        },
+        "verdict": "The workflow file is allocated the single largest slice (30.6%) and delivers ZERO — the hard ceiling drops its whole section. Generated CRUD takes 57.4% of what the agent actually receives; runPayrollCycleAll, BuildPayslip and the real Upsert never reach the response."
+      }
+    },
+    {
+      "id": "self-query",
+      "title": "This repo — incidental `explore`/`BUDGET` matches in the agent-eval scripts",
+      "kind": "self",
+      "path": ".",
+      "query": "how does explore allocate its output budget across files",
+      "rationale": [
+        "The same failure mode with no generated code in sight. `scripts/agent-eval/*.mjs`",
+        "mention `explore` and `BUDGET` incidentally — they are eval harnesses, not the",
+        "allocator — and they are small enough to ship WHOLE, while src/mcp/tools.ts (which",
+        "carries getExploreOutputBudget and the render loop, and scores 4x higher on every",
+        "signal) is large enough to be clipped at maxCharsPerFile. Allocation follows file",
+        "size, not relevance.",
+        "",
+        "Unlike payroll-go this fixture reads THIS repo's live index, so its exact numbers",
+        "move as the repo changes (indexed file count crossing 500 flips the budget tier).",
+        "The assertions are therefore relative — answer-vs-incidental, not fixed percentages."
+      ],
+      "groups": {
+        "answer": ["src/mcp/**"],
+        "incidental": ["scripts/**"]
+      },
+      "assert": {
+        "answerShareAtLeast": 0.5,
+        "incidentalShareAtMost": 0.25,
+        "topFileGroup": "answer",
+        "mustDeliverBytes": ["src/mcp/tools.ts"]
+      },
+      "baseline": {
+        "measuredOn": "2026-08-03",
+        "note": "493 files → small tier (18,000 budget); 27,518 chars allocated against it, cut to 19,749 by the 25,000 hard ceiling.",
+        "delivered": {
+          "scripts/agent-eval/offload-eval-hook.mjs": 0.25,
+          "scripts/agent-eval/offload-eval-metrics.mjs": 0.236,
+          "scripts/agent-eval/parse-session.mjs": 0.232,
+          "src/mcp/tools.ts": 0.185,
+          "scripts/agent-eval/offload-eval-cost.mjs": 0.0
+        },
+        "verdict": "The script corpus takes 71.8% of the delivered envelope (79.4% of what was allocated) against tools.ts's 18.5%, despite tools.ts scoring 46 vs 10, carrying 2.3x the graph mass and 3x the distinct term hits."
+      }
+    }
+  ]
+}

+ 296 - 0
scripts/agent-eval/probe-allocation.mjs

@@ -0,0 +1,296 @@
+#!/usr/bin/env node
+/**
+ * Deterministic per-file budget-share probe for `codegraph_explore` (CG-6).
+ *
+ * `probe-explore.mjs` prints what explore returned. This prints how the response
+ * was DIVIDED — which files won the byte envelope and in what proportion — and
+ * checks that division against a declared expectation. It is the regression gate
+ * for GitHub issue #1500 / epic CG-1: an architecture question that doesn't name
+ * the exact use-case must concentrate the budget on the code that answers it, not
+ * on a generated CRUD layer (or an eval script) that merely name-collides.
+ *
+ * The numbers come from the CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`), read back
+ * from a JSONL sidecar, so the probe measures the shipping allocator rather than
+ * re-deriving shares from the markdown.
+ *
+ * Fixtures are declared in `allocation-fixtures.json`. A `kind: "fixture"` entry is
+ * hermetic — the fixture tree is copied to a fresh temp dir and indexed per run, so
+ * two runs on one build give identical numbers. A `kind: "self"` entry reads this
+ * repo's live index and therefore moves as the repo changes; its assertions are
+ * relative for that reason.
+ *
+ * Usage (needs a current `npm run build`):
+ *   node scripts/agent-eval/probe-allocation.mjs                 # every fixture
+ *   node scripts/agent-eval/probe-allocation.mjs payroll-go      # one fixture
+ *   node scripts/agent-eval/probe-allocation.mjs --json          # machine-readable
+ *   node scripts/agent-eval/probe-allocation.mjs --keep          # keep the temp index
+ *
+ * Exit code: 0 if every assertion holds, 1 if any fails, 2 on a setup error.
+ * BOTH FIXTURES ARE EXPECTED TO FAIL until CG-10/CG-12 land — that failure is the
+ * documented bug. Use --expect-fail to invert the exit code while it is the state
+ * of the world (0 = still broken, 1 = fixed, go flip the gate).
+ */
+import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(HERE, '../..');
+const SPEC_PATH = join(HERE, 'allocation-fixtures.json');
+
+const argv = process.argv.slice(2);
+const flags = new Set(argv.filter((a) => a.startsWith('--')));
+const wanted = argv.filter((a) => !a.startsWith('--'));
+const asJson = flags.has('--json');
+const keepTemp = flags.has('--keep');
+const expectFail = flags.has('--expect-fail');
+
+const say = (line = '') => { if (!asJson) console.log(line); };
+const pct = (f) => `${(f * 100).toFixed(1)}%`;
+const num = (n) => Math.round(n).toLocaleString('en-US');
+
+/** Load the built dist — the probe measures the shipping allocator, not src. */
+async function loadDist() {
+  const distIndex = join(REPO_ROOT, 'dist/index.js');
+  if (!existsSync(distIndex)) {
+    console.error('dist/ not built — run `npm run build` first.');
+    process.exit(2);
+  }
+  const idx = await import(pathToFileURL(distIndex).href);
+  const tools = await import(pathToFileURL(join(REPO_ROOT, 'dist/mcp/tools.js')).href);
+  // esModuleInterop: dynamic import of CJS yields { default: module.exports, ...named }
+  const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph;
+  const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler;
+  if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') {
+    console.error('could not resolve CodeGraph/ToolHandler from dist/');
+    process.exit(2);
+  }
+  return { CodeGraph, ToolHandler };
+}
+
+/** `internal/gen/**` → /^internal\/gen\/.*$/ . Supports `**`, `*` and literals. */
+function globToRegExp(glob) {
+  // Park `**` on a sentinel no path can contain, so the `*` pass cannot eat it.
+  // Written as an escape, not a literal byte — a raw NUL makes git treat this
+  // whole script as binary, which costs every future diff of it.
+  const DOUBLE_STAR = '\u0000';
+  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
+  const body = escaped
+    .replace(/\*\*/g, DOUBLE_STAR)
+    .replace(/\*/g, '[^/]*')
+    .replaceAll(DOUBLE_STAR, '.*');
+  return new RegExp(`^${body}$`);
+}
+
+const groupOf = (path, groups) => {
+  for (const [name, globs] of Object.entries(groups)) {
+    if (globs.some((g) => globToRegExp(g).test(path))) return name;
+  }
+  return 'other';
+};
+
+/**
+ * Run one explore call with the diagnostic pointed at a sidecar, and return the
+ * report plus the response text.
+ */
+async function runExplore({ CodeGraph, ToolHandler }, repoPath, query, sidecar) {
+  const cg = CodeGraph.openSync(repoPath);
+  const prior = process.env.CODEGRAPH_EXPLORE_DEBUG;
+  process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+  try {
+    const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
+    const text = res.content?.[0]?.text ?? '';
+    const lines = readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
+    if (lines.length === 0) throw new Error('diagnostic produced no report');
+    return { report: JSON.parse(lines[lines.length - 1]), text };
+  } finally {
+    if (prior === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+    else process.env.CODEGRAPH_EXPLORE_DEBUG = prior;
+    try { cg.close?.(); } catch {}
+  }
+}
+
+/** Copy a fixture tree to a fresh temp dir and index it — hermetic per run. */
+async function materializeFixture({ CodeGraph }, fixturePath) {
+  const src = resolve(REPO_ROOT, fixturePath);
+  if (!existsSync(src)) throw new Error(`fixture tree not found: ${src}`);
+  const dir = mkdtempSync(join(tmpdir(), 'cg-alloc-'));
+  cpSync(src, dir, { recursive: true });
+  // A stray index inside the checked-in tree would be copied in and reused.
+  rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
+  const cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+  cg.close?.();
+  return dir;
+}
+
+/** Evaluate one fixture's assertions against its report. Returns check rows. */
+function evaluate(fixture, report, text) {
+  const { groups, assert: want } = fixture;
+  const delivered = new Map();
+  const allocated = new Map();
+  for (const f of report.files) {
+    const g = groupOf(f.path, groups);
+    delivered.set(g, (delivered.get(g) ?? 0) + f.share);
+    allocated.set(g, (allocated.get(g) ?? 0) + f.allocatedShare);
+  }
+  const share = (g) => delivered.get(g) ?? 0;
+  const top = report.files
+    .filter((f) => f.finalChars > 0)
+    .sort((a, b) => b.finalChars - a.finalChars)[0];
+
+  const checks = [];
+  const add = (name, pass, detail) => checks.push({ name, pass, detail });
+
+  if (want.answerShareAtLeast !== undefined) {
+    add(
+      `answer group takes >= ${pct(want.answerShareAtLeast)} of the envelope`,
+      share('answer') >= want.answerShareAtLeast,
+      `answer ${pct(share('answer'))} delivered (${pct(allocated.get('answer') ?? 0)} allocated)`,
+    );
+  }
+  if (want.incidentalShareAtMost !== undefined) {
+    add(
+      `incidental group takes <= ${pct(want.incidentalShareAtMost)} of the envelope`,
+      share('incidental') <= want.incidentalShareAtMost,
+      `incidental ${pct(share('incidental'))} delivered (${pct(allocated.get('incidental') ?? 0)} allocated)`,
+    );
+  }
+  if (want.topFileGroup) {
+    const actual = top ? groupOf(top.path, groups) : '(nothing delivered)';
+    add(
+      `largest delivered file is in "${want.topFileGroup}"`,
+      actual === want.topFileGroup,
+      top ? `${top.path} (${pct(top.share)}, group "${actual}")` : 'no file delivered any source',
+    );
+  }
+  for (const path of want.mustDeliverBytes ?? []) {
+    const rec = report.files.find((f) => f.path === path);
+    add(
+      `${path} delivers source`,
+      !!rec && rec.finalChars > 0,
+      rec
+        ? `${num(rec.finalChars)} delivered of ${num(rec.emittedChars)} allocated` +
+          (rec.finalChars === 0 && rec.emittedChars > 0 ? ' — hard ceiling dropped the whole section' : '') +
+          (rec.emittedChars === 0 ? ` — never rendered (${rec.skipped ?? 'not reached'}, rank #${rec.rank})` : '')
+        : 'not among the ranked candidates',
+    );
+  }
+  for (const needle of want.mustContain ?? []) {
+    add(`response contains "${needle}"`, text.includes(needle), text.includes(needle) ? 'present' : 'absent');
+  }
+  return { checks, delivered, allocated, top };
+}
+
+function printReport(fixture, report, evaluated) {
+  const { checks, delivered, allocated } = evaluated;
+  const env = report.envelope;
+  say('');
+  say(`── ${fixture.id} — ${fixture.title}`);
+  say(`   query   "${report.query}"`);
+  say(`   project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
+  say(
+    `   envelope ${num(env.chars)} delivered · ${num(env.allocatedChars)} allocated` +
+    ` of ${num(report.budget.maxOutputChars)} budget (hard ceiling ${num(report.budget.hardCeiling)})` +
+    `${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`,
+  );
+  say('');
+  say('   group        alloc%  deliv%');
+  for (const g of ['answer', 'incidental', 'other']) {
+    if (!delivered.has(g) && !allocated.has(g)) continue;
+    say(`   ${g.padEnd(12)} ${pct(allocated.get(g) ?? 0).padStart(6)}  ${pct(delivered.get(g) ?? 0).padStart(6)}`);
+  }
+  say('');
+  say('    #  alloc%  deliv%    bytes  score    graph  hits  gen  render     file');
+  for (const f of report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0)) {
+    say(
+      '   ' + String(f.rank).padStart(2) + '  ' +
+      pct(f.allocatedShare).padStart(6) + '  ' +
+      pct(f.share).padStart(6) + '  ' +
+      num(f.emittedChars).padStart(7) + '  ' +
+      String(f.score).padStart(5) + '  ' +
+      f.graphScore.toFixed(5).padStart(7) + '  ' +
+      String(f.termHits).padStart(4) + '  ' +
+      (f.generated ? ' ✓ ' : '   ') + '  ' +
+      ((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + '  ' +
+      f.path,
+    );
+  }
+  say('');
+  for (const c of checks) say(`   ${c.pass ? 'PASS' : 'FAIL'}  ${c.name}\n         ${c.detail}`);
+}
+
+async function main() {
+  const spec = JSON.parse(readFileSync(SPEC_PATH, 'utf-8'));
+  const fixtures = spec.fixtures.filter((f) => wanted.length === 0 || wanted.includes(f.id));
+  if (fixtures.length === 0) {
+    console.error(`no fixture matched ${JSON.stringify(wanted)}; known: ${spec.fixtures.map((f) => f.id).join(', ')}`);
+    process.exit(2);
+  }
+
+  const dist = await loadDist();
+  const sidecarDir = mkdtempSync(join(tmpdir(), 'cg-alloc-diag-'));
+  const results = [];
+  const temps = [];
+
+  for (const fixture of fixtures) {
+    let repoPath;
+    if (fixture.kind === 'fixture') {
+      repoPath = await materializeFixture(dist, fixture.path);
+      temps.push(repoPath);
+    } else {
+      repoPath = resolve(REPO_ROOT, fixture.path);
+      if (!existsSync(join(repoPath, '.codegraph'))) {
+        console.error(`${fixture.id}: ${repoPath} has no .codegraph index — run \`codegraph init\` there first.`);
+        process.exit(2);
+      }
+    }
+
+    const sidecar = join(sidecarDir, `${fixture.id}.jsonl`);
+    mkdirSync(dirname(sidecar), { recursive: true });
+    const { report, text } = await runExplore(dist, repoPath, fixture.query, sidecar);
+    const evaluated = evaluate(fixture, report, text);
+    printReport(fixture, report, evaluated);
+    results.push({
+      id: fixture.id,
+      kind: fixture.kind,
+      query: fixture.query,
+      passed: evaluated.checks.every((c) => c.pass),
+      checks: evaluated.checks,
+      shares: {
+        delivered: Object.fromEntries(evaluated.delivered),
+        allocated: Object.fromEntries(evaluated.allocated),
+      },
+      envelope: report.envelope,
+      files: report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0),
+    });
+  }
+
+  if (!keepTemp) {
+    for (const dir of temps) rmSync(dir, { recursive: true, force: true });
+    rmSync(sidecarDir, { recursive: true, force: true });
+  } else {
+    say('');
+    say(`   kept: ${[...temps, sidecarDir].join(' ')}`);
+  }
+
+  const allPassed = results.every((r) => r.passed);
+  if (asJson) {
+    console.log(JSON.stringify({ passed: allPassed, fixtures: results }, null, 2));
+  } else {
+    say('');
+    for (const r of results) say(`${r.passed ? 'PASS' : 'FAIL'}  ${r.id}`);
+    if (!allPassed) {
+      say('');
+      say('Failures here are the DOCUMENTED #1500 bug — the budget goes to files that merely');
+      say('name-collide with the query. They become the pass gate once CG-10/CG-12 land.');
+    }
+  }
+  process.exit(expectFail ? (allPassed ? 1 : 0) : (allPassed ? 0 : 1));
+}
+
+main().catch((err) => {
+  console.error(err?.stack ?? String(err));
+  process.exit(2);
+});