PeerLabs plsec
The signal

Tests told us the architecture was wrong

plsec has a three-tier test strategy: Tier 1 (pure logic, no mocking), Tier 2 (filesystem with tmp_path), and Tier 3 (subprocess mocking). Tiers 1 and 2 were clean. Tier 3 was where the trouble surfaced.

Writing subprocess-mocking tests for doctor.py required 5 simultaneous mocks and offered zero pure-logic seams. Testing scan.py meant mocking 4 bespoke run_*() functions individually. Every test was fighting the structure of the code rather than verifying its behaviour.

The test difficulty was not a testing problem. It was an architecture problem surfaced by tests. The code was hard to test because knowledge about agents, scanners, and processes was scattered across command files rather than declared in one place.

The evidence

A codebase trace made the cost visible

Before proposing any solution, we traced the change cost of two scenarios: adding a new AI agent and adding a new security scanner. The results were unambiguous.

Scenario Files to change Lines to touch
Add a new AI agent (e.g., Gemini CLI) ~35 50+
Add a new security scanner ~15 30+

The root cause was scattered metadata. Agent knowledge lived in 12+ if/elif branches across init.py, secure.py, create.py, validate.py, and doctor.py. Each branch hardcoded which file to create, which template to use, and which detector field to check. Scanner invocation was similarly ad hoc: 4 separate run_<tool>() functions in scan.py, each constructing subprocess calls with bespoke argument lists.

One specific pattern illustrated the scaling problem. The CLI used "both" as a special value for the --agent flag: AgentType = Literal["claude", "opencode", "both"]. This appeared in 3 command files. Adding a third agent would make "both" ambiguous. The concept should have been "all configured agents", not an enumerated variant.

The collaboration

Human drives architecture, agent does design labour

The refactoring was designed collaboratively between a human architect and Claude. The division of labour was deliberate and consistent throughout.

The human's role: problem identification from testing experience, codebase trace, architectural direction (entity-operation separation), design principles, phase sequencing, and the decision to use existing tests as the regression safety net. Every architectural judgment was human-driven.

The agent's role: detailed design specification (dataclass definitions, registry shapes, consumer migration patterns), before/after code examples, impact analysis tables, and consistency verification across the design document. The agent did the high-volume design labour under architectural direction.

The key discipline: the human set the constraints and evaluated the output, the agent generated within those constraints. The agent never decided the architecture. It explored the design space defined by the human's principles and produced detailed specifications for review. This is not pair programming. It is directed design with an agent doing the elaboration.

The resulting design document carries the credit line "Gylthuin (with AI Assistance from Claude)" -- reflecting this division accurately. The architecture is human-authored. The specification is collaboratively produced.

The design

Entity-operation separation with three registries

Five design principles governed the refactoring:

Declare once, consume everywhere. Metadata about agents, scanners, and processes lives in registry modules. Commands iterate the registries; they do not hardcode entity knowledge.

Entities and operations are separate concerns. Registries describe what exists (nouns). Commands describe what to do (verbs). The interface between them is iteration.

New entity, one file change. Adding a new agent requires adding one AgentSpec entry. Adding a new scanner requires one ScannerSpec entry. No if/elif changes in command files.

Testable without mocking. Registry data and check functions are pure or near-pure -- taking Path arguments, not calling global state internally. Mocking is reserved for subprocess calls, not for plumbing.

Don't over-abstract. The registries are plain dicts of dataclasses, not metaclass-driven discovery mechanisms. If it doesn't simplify testing or reduce duplication, it doesn't belong.

Entity-operation model
  Entities (Registries)              Operations (Commands)
  =====================              =====================

  AGENTS                  ------>    CREATE / INIT / SECURE
  (claude, opencode, ...)            "produce config files"

  SCANNERS                ------>    SCAN
  (trivy-secrets, bandit, ...)       "run security checks"

  PROCESSES               ------>    PROXY (start/stop/status)
  (pipelock, ...)                    "manage background services"

  All three               ------>    DOCTOR / STATUS
                                     "check environment health"
The transformation

What the code looks like before and after

The consumer code tells the story more clearly than the registry definitions. Here is how secure.py's change calculation transformed -- from per-agent blocks to a generic loop.

Before: per-agent blocks
# 15 lines per agent, duplicated
if "claude" in state.agents:
    content = CLAUDE_MD_STRICT \
        if is_strict else CLAUDE_MD_BALANCED
    if not info.has_claude_md:
        changes.creates.append(...)
    elif force:
        changes.modifies.append(...)
    else:
        changes.conflicts.append(...)

# Same 15 lines for opencode
if "opencode" in state.agents:
    ...
After: registry iteration
# Generic, works for any agent
for agent_id in state.agents:
    spec = AGENTS[agent_id]
    _add_agent_config_change(
        changes, spec, info,
        security_mode(state.preset),
        force,
    )

# Adding Gemini: zero changes here.
# Just add AgentSpec to registry.



The same transformation applied across every consumer. The full impact table:

Consumer Before After
init.py if/elif blocks x4 per agent for agent_id in resolve_agent_ids(): ...
secure.py Per-agent change calculation Generic _add_agent_config_change()
scan.py 4 bespoke run_*() functions Generic loop over SCANNERS registry
doctor.py 140-line monolith, 5 mocks to test 25-line orchestrator calling pure check functions
detector.py has_claude_md, has_opencode_json booleans detected_agents: dict populated by iterating AGENTS
wizard.py Hardcoded AGENT_CHOICES list Generated from AGENTS.values()
proxy.py Hardcoded Pipelock functions Generic functions taking ProcessSpec
The execution

Phased migration with a regression safety net

The refactoring was split into phases so the test suite stayed green at every step. The existing 216 tests (Tiers 1 and 2) served as the regression safety net. If any failed during consumer rewiring, the change was incorrect.

A

Foundation

Create 4 new registry modules (agents.py, scanners.py, processes.py, health.py) with registries and pure functions. No consumer changes. Purely additive.

Verify: make ci passes (new files are additive)
B

Rewire consumers

One command file at a time, in dependency order: detector.py, wizard.py, config.py, init.py, secure.py, create.py, validate.py, doctor.py, scan.py, proxy.py. Each file changed in a single commit.

Verify: pytest after each file change
C

Cleanup

Fix all except Exception catches (3 files). Narrow to specific exception types. Zero except Exception remaining in the codebase.

Verify: make ci passes
D

Full verify

Run full make ci. All 216 existing tests pass. No regressions.

Verify: make ci green
E

Registry tests

Write test files for the 4 registry modules. 142 new tests. These define the contracts for the registry architecture going forward.

Verify: pytest all pass (358 total)
F

Command tests

Tier 3 subprocess-mocking tests for the rewired command modules. 74 new tests. The tests that were impossible to write before the refactoring now wrote cleanly.

Verify: pytest all pass (432 total), 69% coverage
The outcome

Measurable results

~35 files
1 file
To add a new agent
~15 files
1 file
To add a new scanner
5 mocks
0 mocks
To test doctor.py logic

The refactoring added 216 new tests (142 registry + 74 command), bringing the total from 216 to 432, with coverage increasing from an unmeasured baseline to 69%. The Tier 3 tests that initially exposed the problem now wrote cleanly against the registry-driven architecture.

The registry model subsequently enabled the engine architecture. The engine registry (EngineRegistry) follows the same pattern: declare engines in a registry, the orchestrator iterates them. Adding a new security engine means implementing one Engine subclass and calling register(). No control flow changes.

Observations

What generalises from this experience

Testing difficulty is an architecture signal, not a testing problem. When Tier 3 tests required extensive mocking, the correct response was not "improve the mocks" but "fix the code structure so the logic is testable without mocks." The tests were functioning as a design pressure, not just a verification mechanism.

Evidence before solution. The codebase trace (35 files, 50+ lines) preceded the design. The numbers made the case for the refactoring in a way arguing about principles could not. Quantifying the change cost took an hour. It saved weeks of debate about whether the refactoring was justified.

Agents are good at elaboration, bad at judgment. The agent produced detailed dataclass definitions, migration tables, and consumer impact analyses quickly and accurately. It did not identify the architecture problem, articulate the design principles, or decide the phasing strategy. The human-agent boundary was at the judgment/elaboration line, and maintaining it was essential.

Existing tests are the best refactoring safety net. The 216 pre-existing tests caught every consumer-rewiring mistake immediately. The phased approach (registries first, consumers second, new tests last) meant we always had a green test suite to fall back to. Writing new tests before the refactoring would have been wasted effort -- they would have tested the wrong structure.

Registries reduce the blast radius of AI-assisted code changes. With scattered metadata, an agent modifying plsec's codebase had to understand 12+ if/elif branches across 7 files to make a consistent change. With registries, the same change is a single entry in a single file. The architecture now matches the granularity at which agents (and humans) can reliably work.