Skip to content
SQA Cockpit

ADR-0040 — `runners/` as the fifth top-level `src/` folder

ADRsUpdated 8 min readEdit on GitHub ↗
5 sections··

ADR-0040 — runners/ as the fifth top-level src/ folder #

  • Status: Accepted
  • Date: 2026-05-13
  • Deciders: Natan
  • Source: post-PRD-21 cleanup. Agent-1 (PRD-17), Agent-3 (PRD-21)

both landed code that included long-running entrypoints. After PRD-21 closure, src/ accumulated 9 loose files at root (sweep.ts, sweep-aggregate.ts, sweep-render.ts, tail.ts, tail-cursor.ts, tail-row.ts, tail-runner.ts, tail-sample.ts, tail-sink.ts) alongside src/index.ts.

Context

Context #

ADR-0009 constrains when a new top-level folder under src/ is justified: the folder name must match a discipline-level word a reader already knows. ADR-0010 §Falsifiability anticipates this case explicitly: "Adding a fifth top-level folder under src/ requires adding a fifth alias. Cheap; ADR-0009 already constrains when a new top-level folder is justified."

The four existing discipline-level folders are:

  • lib/ — shared infrastructure (logger, result envelope,

retry, gap-log, trace, scenario-state, etc.).

  • components/ — per-external-system capabilities, one folder

per store (mongodb, s3, clickhouse, loki, redis, openrouter, hatchet).

  • config/ — validated env + per-target configuration.
  • systems/ — per-system-under-test flow compositions

(snappy, mock).

Plus the single-entrypoint convention: src/index.ts is the runnable that make verify invokes for a one-shot activation scenario.

After PRD-21, SQA has three entrypoints, not one:

  • src/index.ts — single-run scenario. make verify /

make probe runs it. One activation, then exit.

  • src/sweep.ts (and sweep-*.ts helpers) — parallel batch

runner. make sweep DOMAINS=<file> spawns N Bun subprocesses against a domain list, aggregates results, renders summaries. Used for the 499-domain F500 sweep that motivated PRD-17 and PRD-21.

  • src/tail.ts (and tail-*.ts helpers) — continuous-tail

runner (PRD-21 / ADR-0039). Long-running daemon. Polls ClickHouse for new prod activations, samples ~1%, runs the verifier against each, writes results back to CH.

Each entrypoint is more than a single file: sweep ships with its own aggregation + rendering helpers, tail ships with cursor / row-builder / sink / sample-predicate / runner-loop modules. 9 files at src/ root that all share the prefix convention <runner-name>-*.ts are doing the grouping job by filename, but the file tree itself doesn't reflect the grouping.

Three options were considered:

  1. Leave them at src/ root. The naming-prefix convention

(tail-*, sweep-*) was already grouping them visually. Trade-off: 10 files at src/ root vs. the rest of src/ having a dir-per-category shape. Slight visual mess; no real semantic problem. The drift will keep growing as future runners land.

  1. Promote to top-level scripts/. The repo already has

a top-level scripts/ directory (run-summary.ts, etc.). Trade-off: scripts/ contains one-shot utility scripts (compute a summary, lint a config). The continuous-tail runner is a long-running daemon. Mixing these two categories under one directory blurs the distinction and makes future filtering ("show me only the daemons") harder.

  1. A new top-level src/ folder. Names the category

honestly. Trade-off: introduces a fifth top-level discipline-level folder, requiring a fifth path alias per ADR-0010. ADR-0010 §Falsifiability anticipated this; the precondition (a folder name matching a discipline-level word a reader already knows) is the deciding question.

Decision

Decision #

Option 3, with the folder name runners/. Concretely:

text
src/
├── components/          (per-store capability layer)
├── config/              (env + target validation)
├── lib/                 (shared infra)
├── runners/             ← NEW
│   ├── sweep/
│   │   ├── index.ts     ← was src/sweep.ts
│   │   ├── aggregate.ts ← was src/sweep-aggregate.ts
│   │   └── render.ts    ← was src/sweep-render.ts
│   └── tail/
│       ├── index.ts     ← was src/tail.ts
│       ├── cursor.ts    ← was src/tail-cursor.ts
│       ├── row.ts       ← was src/tail-row.ts
│       ├── runner.ts    ← was src/tail-runner.ts
│       ├── sample.ts    ← was src/tail-sample.ts
│       └── sink.ts      ← was src/tail-sink.ts
├── systems/             (per-SUT flow compositions)
└── index.ts             (the third runner — single-run scenario)

Two structural rules follow:

  1. **A "runner" is a long-running or multi-invocation entrypoint

that drives the scenario library** (systems/, components/, lib/). Sweep, tail, future webhook-driven verifiers, continuous-tail variants — all are runners. One-shot utility scripts (run-summary.ts, ad-hoc diagnostics, build-time helpers) stay in the top-level scripts/ directory; they are not runners.

  1. src/index.ts is the third runner. It does single-run

scenario invocation. It stays at src/ root because it is the default entrypoint — bun run src/index.ts and make verify are the canonical invocations. The single-run runner having no sub-folder is intentional asymmetry: it's the unmarked default; sweep and tail are the marked alternatives.

A fifth path alias is added per ADR-0010:

json
{
  "@lib/*":        ["src/lib/*"],
  "@components/*": ["src/components/*"],
  "@config/*":     ["src/config/*"],
  "@systems/*":    ["src/systems/*"],
  "@runners/*":    ["src/runners/*"]   // ← new
}

Cross-folder imports into runners use @runners/sweep/aggregate.ts, @runners/tail/cursor.ts, etc. Same-folder imports (inside runners/sweep/, or inside runners/tail/) stay relative (./aggregate.ts, ./cursor.ts) per ADR-0010.

Consequences

Consequences #

Architectural #

  • The discipline-level vocabulary grows from four to five.

Before: lib / components / config / systems. After: same four plus runners. The fifth word is honest — it names a category the codebase actually has, distinct from the other four. ADR-0009's "directory name matches the discipline" rule is satisfied.

  • Future runners land in runners/<name>/ without further

ADR work. The precedent is set by sweep/ and tail/. If someone adds a Kafka-driven runner tomorrow, it becomes runners/kafka/.

  • The runners/ boundary is also a Makefile boundary. Each

runner directory's index.ts is the runnable. Makefile targets follow the path: make sweep runs src/runners/sweep/index.ts; make tail runs src/runners/tail/index.ts. Adding a runner = a directory

  • a Makefile target.
  • The four other top-level folders are unchanged. No

refactor cascades into lib/, components/, config/, systems/. The change is additive.

Behavioural #

  • src/ root is two entries plus categories. src/

becomes: components/, config/, index.ts, lib/, runners/, systems/. Six entries total. Every file under the root either is the single-run default entrypoint or lives in a discipline-level directory. The "9 loose runner files at root" smell is gone.

  • Test files mirror the source structure.

__tests__/runners/sweep/aggregate.test.ts mirrors runners/sweep/aggregate.ts. Same pattern as the existing __tests__/components/clickhouse/insert.test.ts mirrors components/clickhouse/insert.ts.

  • Import paths become readable. A test that imports a

runner internal now reads import { buildTailRow } from "@runners/tail/row.ts" rather than from "../../../runners/tail/row.ts". The alias names the discipline-level layer, same as @lib/... / @components/... already do.

Falsifiability #

  • **If a directory named runners/ accumulates files that

aren't long-running or multi-invocation entrypoints** — for example, a shared helper used only by runners but not itself runnable — this ADR has failed. The helper should live in lib/ (if generally useful) or in the specific runners/<name>/ subdirectory it serves (if scoped to one runner). runners/ is not a junk drawer for "things related to running."

  • If the next entrypoint to land bypasses runners/ (lands

as src/<thing>.ts instead of src/runners/<thing>/), this ADR has failed. The cleanup this ADR records was needed precisely because the precedent wasn't set; setting it forward means future entrypoints honor the directory or this ADR is re-litigated.

  • **If src/index.ts ever stops being the single-run default

entrypoint** (e.g. becomes a thin shim that re-exports from runners/single-run/index.ts), this ADR has failed in the opposite direction — the unmarked-default carve-out for src/index.ts is load-bearing. The single-run entrypoint having no sub-folder is what makes make verify and bun run src/index.ts the obvious defaults.

  • If runners/ and scripts/ start to overlap — a

long-running daemon lands in scripts/, or a one-shot utility lands in runners/ — this ADR has failed. scripts/ is one-shot utilities; runners/ is entrypoints into the scenario library. The distinction must stay sharp or the new category dilutes.

Alternatives considered

Alternatives considered #

  1. **Leave files at src/ root with the naming-prefix

convention.** Rejected. The prefix convention was visual grouping by filename; the tree itself didn't reflect the grouping. Future runners (PRD-15 may add a negative-claim verifier daemon; ad-hoc webhook handlers will land at some point) would keep accumulating at root.

  1. Promote to top-level scripts/. Rejected. The

long-running-daemon vs one-shot-utility distinction is load-bearing. tail runs forever; run-summary.ts exits in seconds. Same directory blurs the boundary; future filtering ("show me the daemons under k8s management") is harder when the categories share a directory.

  1. A new top-level entrypoints/ directory at repo root

(sibling to src/, not under it). Rejected. The entrypoints share the same import surface as the rest of src/ (the @lib/, @components/, etc. aliases all originate in src/). Promoting them to a sibling of src/ would force them to import via relative paths (../src/lib/logger) or duplicate the path-alias config in a new tsconfig. Both are worse than living inside src/ under a discipline-level folder.

  1. src/cmd/ matching the Go convention. Rejected. The

cmd/ convention is a Go-community shape; in a TypeScript codebase that uses discipline-level vocabulary (ADR-0009), cmd is a foreign word. runners matches lib / components / systems in being a discipline-level descriptor.

  1. src/bin/ matching the Unix / Rails convention.

Rejected. bin/ traditionally holds binary output, not source. A reader who knows the convention from elsewhere would expect built artifacts, not TypeScript source. The word is misleading for TypeScript source code that runs via bun run.

See also

See also #

discipline-name-matching rule this ADR honors.

per top-level folder; §Falsifiability anticipated this exact case and is now exercised. The fifth alias (@runners/*) is added.

subsystem that motivated cleanup (its tail-* files are what landed at root).

shipped tail as a runner.

  • Makefile — the runner-to-Makefile-target

binding (make sweep, make tail).

Was this page helpful?