ADR-0040 — `runners/` as the fifth top-level `src/` folder
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(andsweep-*.tshelpers) — 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(andtail-*.tshelpers) — 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:
- 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.
- 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.
- 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:
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:
- **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.
src/index.tsis 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:
{
"@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.tsever 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/andscripts/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 #
- **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.
- 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.
- 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.
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.
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 #
- ADR-0009 — the
discipline-name-matching rule this ADR honors.
- ADR-0010 — path aliases
per top-level folder; §Falsifiability anticipated this exact case and is now exercised. The fifth alias (@runners/*) is added.
- ADR-0039 — the continuous-tail
subsystem that motivated cleanup (its tail-* files are what landed at root).
- PRD-21 ↗ — the PRD that
shipped tail as a runner.
Makefile— the runner-to-Makefile-target
binding (make sweep, make tail).