Architecture

A walk through what’s in this repo, where each concern lives, and how the runtime pieces connect when a TFactory task fires. Everything below reflects what’s actually on main as of the last commit — see Progress for the live task status.

Pipeline spine (5 lanes)

flowchart TD
    H["AIFactory branch / Claude Code / any tool"] -->|"/handover-to-tfactory"| MCP["TFactory MCP"]
    MCP --> P["Planner<br/>(test_plan.json)"]

    P --> U["Gen-Unit"]
    P --> B["Gen-Browser"]
    P --> A["Gen-API"]
    P --> I["Gen-Integration"]
    P --> M["Gen-Mutation"]

    U --> X["Executor<br/>(sandboxed per subtask · .tfactory.yml target addressing;<br/>browser lane in a Nix k8s Job, RFC-0005 Tier A)"]
    B --> X
    A --> X
    I --> X
    M --> X

    X --> E["Evaluator<br/>(five-signal verdict: coverage · stability ·<br/>mutation · lint-promotion · semantic-relevance)"]
    E --> T["Triager<br/>(update-in-place vs create-new via tests-catalog.json)"]
    T --> R["git commit + PR comment<br/>(dry-run by default)"]

The middle row is the five-lane spine — one generator per modality (unit, browser, api, integration, mutation).

All five lanes are wired (TFactory v0.9.x). Lane dispatch is gated per the Lane enum: Lane.UNIT runs pytest; Lane.BROWSER runs Playwright in a per-task Nix toolchain inside an ephemeral Kubernetes Job (RFC-0005 Tier A), capturing screenshots (findings/screenshots/) and Playwright video recordings (findings/videos/); Lane.API and Lane.INTEGRATION use the same per-framework Docker runner image dispatch plus the HTTP HAR recorder from agents/evidence/http_recorder.py; Lane.MUTATION shells out to Stryker for TypeScript or mutate_probe.py for Python. Evidence artefacts (screenshots / video / trace / HAR) are captured per test, served by the portal and rendered in the task-detail Acceptance and Evidence tabs, and also surfaced in the CFactory cockpit on the finished task.

Lane status

Lane Phase Framework examples Status
Unit 1 pytest, Jest, vitest Active
Browser 2 Playwright (chromium/firefox/webkit) Active (Nix toolchain in ephemeral Kubernetes Job)
API 3 pytest-httpx, supertest, dredd Active
Integration 4 testcontainers-python, testcontainers-node Active
Mutation 5 mutmut, cosmic-ray, Stryker Active

The framework descriptor registry (framework_registry/) catalogs 80 frameworks across the five lanes; .tfactory.yml configures targets (HTTP / kubernetes / docker_compose / feature_flag) for each lane. The tests-catalog (tests_catalog/) persists cross-run continuity via tests-catalog.json committed alongside generated tests.

Review lane (analysis, opt-in)

The review lane (apps/backend/agents/review_lane.py) is an analysis lane that runs an LLM “staff engineer” reviewer over the build’s changed code and writes findings to findings/review.json. It is not part of the 5-lane test-runner spine above — it produces a complementary verify signal alongside the test lanes and never touches the Evaluator / Triager / verdict contract.

The review lane is vendored from agent-skills and gated so the default verify path is unchanged when the flag is unset.

Liveness watchdog

apps/backend/agents/liveness.py turns a silently hung stage into an explicit stalled status the portal (or a watcher) can surface. A stage that hangs leaves status.json in an active “-ing” state with a frozen updated_at; the watchdog compares that timestamp against now and, past the idle budget, flips the task to status="stalled" (preserving the prior state as stalled_from, recording stall_idle_seconds, and emitting a stage event).

CLI commands

# Scaffold a new .tfactory.yml + empty tests-catalog.json
python -m cli init
python -m cli init --non-interactive --target-name api \
    --target-type http --base-url https://api.staging.example.com

# Migrate v0.1 workspace tests to the new catalog format
python -m cli migrate v0_1_catalog
python -m cli migrate v0_1_catalog --dry-run

Repository layout (depth 3)

TFactory/
├── apps/
│   ├── backend/                  # Python 3.12, Claude Agent SDK
│   │   ├── agents/
│   │   │   ├── memory_manager.py        # Graphiti + file fallback
│   │   │   ├── planner.py               # ← Task 5 will rewrite
│   │   │   ├── session.py
│   │   │   ├── utils.py
│   │   │   └── tools_pkg/
│   │   │       ├── registry.py          # spec-internal tool registry
│   │   │       ├── tools/
│   │   │       │   ├── memory.py        # in-agent: graphiti ops
│   │   │       │   ├── progress.py      # in-agent: status updates
│   │   │       │   ├── qa.py            # in-agent: validation
│   │   │       │   ├── subtask.py       # in-agent: subtask state
│   │   │       │   └── task_control.py  # MVP MCP surface (7 tools)
│   │   │       └── http_client.py
│   │   ├── mcp_server/
│   │   │   └── tfactory_server.py       # stdio MCP entrypoint
│   │   ├── test_plan/                   # ← renamed from implementation_plan
│   │   │   ├── enums.py                 # Lane enum added in Task 3
│   │   │   ├── subtask.py               # .lane field added in Task 3
│   │   │   ├── phase.py
│   │   │   ├── plan.py                  # ImplementationPlan model
│   │   │   ├── story.py
│   │   │   ├── verification.py
│   │   │   └── factories.py
│   │   ├── workspaces/                  # NEW in Task 3
│   │   │   └── snapshotter.py           # AIFactory → TFactory snapshot
│   │   ├── tools/
│   │   │   ├── executor.py              # in-agent tool runner (inherited)
│   │   │   ├── definitions.py
│   │   │   └── runners/                 # NEW in Task 4
│   │   │       ├── docker_runner.py     # sandboxed test exec
│   │   │       ├── lane_dispatch.py     # lane → runner routing
│   │   │       └── lang_registry.py     # per-lang, per-lane tool table
│   │   ├── prompts/                     # ← Tasks 5-8 add new prompts here
│   │   ├── providers/                   # OpenAI / Anthropic / Ollama factory
│   │   ├── context/                     # project_analyzer (reused)
│   │   ├── memory/, core/, integrations/, runners/
│   ├── web-server/                      # FastAPI — Task 9 retheme
│   └── frontend-web/                    # React — Task 10 retheme
├── docker/
│   └── runners/
│       └── python.Dockerfile            # NEW in Task 4
├── .claude/
│   └── skills/
│       └── handover-to-tfactory/        # NEW in Task 2
│           └── SKILL.md
├── companion-skills/                    # NEW in Task 2
│   └── aifactory-handover-to-tfactory/
│       └── SKILL.md                     # installs into AIFactory
├── docs/                                # Jekyll source for this site
│   ├── _config.yml
│   ├── index.md
│   ├── architecture.md                  # ← you are here
│   ├── progress.md
│   ├── design-plan.md
│   ├── spec.md
│   ├── technical-spec.md
│   ├── tests.md
│   └── tasks.md
├── .agent-os/
│   └── specs/2026-05-28-tfactory-mvp-walking-skeleton/
│       ├── spec.md, tasks.md
│       └── sub-specs/{technical-spec.md, tests.md}
├── scripts/
│   ├── verify-fork.sh                   # NEW in Task 1
│   ├── start-tfactory-mcp.sh            # renamed from start-aifactory-mcp.sh
│   └── ... (other inherited scripts)
├── tests/
│   ├── test_tfactory_mcp_tools.py       # Task 2 (21 cases)
│   ├── test_test_plan_lane.py           # Task 3 (10 cases)
│   ├── test_snapshotter.py              # Task 3 (11 cases)
│   ├── test_docker_runner.py            # Task 4 (28 cases)
│   ├── test_lang_registry.py            # Task 4 (10 cases)
│   ├── test_lane_dispatch.py            # Task 4 (10 cases)
│   └── ... (inherited; some quarantined)
└── charts/tfactory/                     # Helm chart (renamed)

Entries marked “NEW” / “Task N” are TFactory-original work; everything else is inherited from the AIFactory fork and adapted by string-replace.

Workspace layout (runtime)

~/.tfactory/
├── projects.json                        # { projects: [{ id, name, root_path, created_at }] }
└── workspaces/
    └── {project_id}/
        └── specs/
            └── {spec_id}/
                ├── task.md              # handover payload (markdown)
                ├── status.json          # lifecycle state, lane_progress
                ├── context/             # ← populated by Task 3 snapshotter
                │   ├── source.json      #   { aifactory_spec_dir, branch, base_ref, sha, ... }
                │   ├── aifactory_spec.md    (mode 0o444)
                │   ├── aifactory_plan.json  (mode 0o444)
                │   └── diff.patch       #   base_ref..branch
                ├── tests/               # ← Gen-Functional writes pytest files (Task 6)
                │   └── functional/
                ├── findings/            # ← verdicts.json + triage_report + evidence/
                ├── logs/                # ← per-agent transcripts (Tasks 5+)
                ├── memory/              # ← session insights (Tasks 5+)
                ├── report.md            # ← Triager output (Task 8)
                └── report.json

Cross-reference is one-way: TFactory reads ~/.aifactory/workspaces/{project_id}/specs/{spec_id}/ read-only at handover time, copies relevant files into context/ at mode 0o444, and operates on the snapshot thereafter. The upstream AIFactory spec can change without breaking in-flight TFactory work.

Handover dataflow

┌────────────────────┐        /handover-to-tfactory
│ AIFactory project  │  ───►  (Claude Code skill)
│ at <root_path>     │              │
│ branch = feature/* │              │  MCP call over stdio
└────────────────────┘              ▼
                            mcp__tfactory__task_create_and_run
                            { project_id, spec_id, branch, base_ref, confirm }
                                    │
                                    ▼
         ┌──────────────────────────────────────────────┐
         │ apps/backend/agents/tools_pkg/tools/         │
         │   task_control.py                            │
         │ ─────────────────────────────────────────── │
         │ • look up project in projects.json           │
         │ • mkdir ~/.tfactory/workspaces/.../specs/X/  │
         │ • write task.md + status.json (status=pending│
         │ • call snapshot_aifactory_spec(...)          │
         │   if SnapshotError → rollback + MCP error    │
         │ • return { task_id, spec_dir, portal_url }   │
         └────────────────┬─────────────────────────────┘
                          │
                          ▼
            ~/.tfactory/workspaces/.../specs/X/
                          │
                          │  the pipeline picks up here:
                          ▼
                  Planner agent reads context/aifactory_spec.md +
                  context/diff.patch and emits test_plan.json
                          │
                          ▼
                  Gen-Functional reads test_plan.json, generates
                  pytest files into tests/functional/ in the workspace
                          │
                          ▼
                  Executor calls dispatch_lane("functional", ...)
                  → DockerRunner.run_pytest(...) in tfactory-runner-python
                          │
                          ▼
                  Evaluator scores coverage delta + flake-lint + mutate-
                  and-check sanity probe → per-test verdicts
                          │
                          ▼
                  Triager dedups + ranks + renders report.md, commits
                  accepted tests to the AIFactory feature branch, runs
                  `gh pr comment <pr>` with the report body

Runner stack (Task 4)

                ┌──────────────────────┐
                │   dispatch_lane()    │   thin lane → runner router
                │   lane_dispatch.py   │
                └──────────┬───────────┘
                           │
        ┌──────────────────┼──────────────────────┐
        │                  │                      │
   unit / browser        api / integration         mutation
        │                  │                      │
        ▼                  ▼                      ▼
  DockerRunner       DockerRunner             mutate probe
  (pytest / jest /    + AppRuntime             (mutmut / Stryker
   playwright)        (browser lane)            per language)
  build_argv()  ←── pure function: argv list
                    --network=none
                    --read-only
                    --cpus=2 --memory=2g
                    --pids-limit=512
                    -v repo:/work:ro
                    -v scratch:/scratch:rw
                    --tmpfs /tmp:rw,size=64m
  run()
   └── subprocess.run(...)
   └── collect /scratch/junit.xml + /scratch/coverage.xml
   └── DockerRunResult { returncode, stdout, stderr, argv,
                          junit_xml_path, coverage_xml_path }

DockerRunner wraps subprocess rather than the docker SDK so swapping docker ↔ podman is a config change. Binary picked from TFACTORY_CONTAINER_BIN env or constructor; default docker.

Tool registry (Task 4)

Lane spine — security scanning is out of scope (delegated to dedicated pipelines); see apps/backend/tools/runners/lang_registry.py for the source of truth.

Language Unit Browser API Integration Mutation
Python pytest playwright-python httpx+pytest testcontainers mutmut
TypeScript jest playwright supertest testcontainers-node stryker
Java / .NET — (v0.3+)
Go / Rust / Ruby — (v0.4+)

All five lanes are lit for Python and TypeScript. lang_registry.py holds the live source.

MCP surface (Task 2)

Seven tools exposed over stdio to Claude Code via .mcp.json and scripts/start-tfactory-mcp.sh:

Tool Purpose Side effects
task_create_and_run Create a TFactory workspace for an AIFactory spec mkdir workspace + snapshot AIFactory spec
task_status Read lifecycle state read-only
task_list List tasks; filter by project / status read-only
project_list List registered AIFactory projects read-only
project_create Register an AIFactory project append to projects.json
report_get Fetch report md / json read-only
task_rerun Re-execute one lane (functional only at MVP) bump rerun_count + reset lane state

All seven defined in task_control.py.

Subtask lane + timing on the task API

GET /api/tasks/{id} exposes per-subtask lane and timing so CFactory’s test-stage execution diagram can render a live lane pipeline. Each subtask in the response carries three additive, optional fields alongside the existing id / title / status / files / verification:

Field Meaning
lane the test lane the subtask belongs to — unit / browser / api / integration / mutation (the Lane enum spine)
started_at ISO-8601 timestamp the subtask began (or null)
completed_at ISO-8601 timestamp the subtask finished (or null)

All three are populated only on lane-tagged test plans and tolerate absence (null) on older or untagged plans — no behaviour change for existing consumers. The subtask status is a free-form string (rather than a fixed enum) so lane states such as stuck / blocked round-trip cleanly to the cockpit. Both the construction site and the serializer live in apps/web-server/server/routes/tasks.py (the Subtask model).

Module dependency graph

        test_plan/  ◄────────┐
        (Lane, Subtask)      │
              ▲              │
              │              │ imports Lane (Task 3+)
              │              │
       workspaces/           │
       snapshotter           │
              ▲              │
              │ used by      │
              │              │
   tools_pkg/tools/          │
     task_control.py  ─────► │
              ▲              │
              │ registered   │
              │              │
       mcp_server/
       tfactory_server.py

       tools/runners/   ◄───── (Task 6 Gen-Functional will import)
       ├── docker_runner.py
       ├── lang_registry.py
       └── lane_dispatch.py
                  ▲
                  │ used by
                  │
       (Task 8 Executor)

Credential Broker (epic #62)

A pluggable secrets layer (apps/backend/tfactory_secrets/) so agents can authenticate to cloud environments without secrets in the repo. It mirrors the LLM-provider patterns and extends — rather than replaces — the existing core/mcp_credentials.py ambient chain.

   agent / MCP tool ─► CredentialBroker.resolve_cloud("gcp"|"aws"|"azure"|"k8s")
                       │  (1) egress gate: .tfactory.yml egress.enabled?  (default OFF)
                       │  (2) backend-fetch head, else ambient mcp_credentials chain
                       ▼
            get_secrets_backend(name)  ◄─ infer_backend_from_ref()  (refs.py)
                       │   env · localfile(sops/age/agenix) · vault ·
                       │   azure_keyvault · aws_secrets_manager · gcp_secret_manager
                       ▼
            materialise ephemerally → env vars + 0600 cred files (kubeconfig,
            ADC json) in a per-task scratch dir, wiped on close()/atexit
                       ▼
            inject into core/client.py agent env (no-op unless egress enabled)
Module Role
__init__.py SecretsBackend ABC + SecretRef / SecretValue (value-redacting repr)
refs.py per-scheme ref parsing + backend routing (mirrors infer_provider_from_model)
factory.py get_secrets_backend() registry + alias map + lazy SDK import
backends/ env, localfile, vault, azure_keyvault, aws_secrets_manager, gcp_secret_manager
broker.py CredentialBroker — cloud resolution, ephemeral materialise + wipe, inject_task_credentials
egress.py .tfactory.yml egress gate + secret-free manifest + badge
redaction.py value-based + pattern redaction; RedactingFilter for loggers
cli.py python -m tfactory_secrets.cli audit\|doctor\|resolve

Design: docs/plans/2026-05-30-credential-broker-design.md. Reference: guides/credentials.md. Cloud SDKs are optional (lazy-imported); a missing package degrades only that backend to available() == False.

Network-lane SSRF guard + fail-closed auth bind (#361 / #362)

The browser / api / integration lanes fetch a target URL that arrives in the AIFactory handoff — i.e. attacker-influenceable input — so the runner stack guards it before any fetch:

   handoff target URL ─► net_guard.assert_safe_target_url(url, allow_*…)
                         │  resolve host (literal IP or every DNS answer)
                         │  ALWAYS block: 169.254.0.0/16 · fe80::/10 · fc00::/7
                         │               (cloud-metadata / link-local — no override)
                         │  block loopback unless allow_loopback=True
                         │               (AppRuntime compose health-poll opts in)
                         │  block RFC-1918 unless allow_private=True
                         ▼
              UnsafeTargetURLError  ─► lane refuses to fetch
Module Role
apps/backend/tools/runners/net_guard.py stdlib-only SSRF guard; assert_safe_target_url / is_safe_target_url
apps/backend/tools/runners/app_runtime.py compose health-poll calls the guard with allow_loopback=True (localhost is legitimate here)
apps/backend/tools/runners/lane_dispatch.py validates the untrusted handoff URL with no allow-flags before dispatch
tests/test_net_guard.py range coverage incl. mixed public/internal DNS answers

The metadata / link-local ranges are blocked unconditionally — there is no global “off” switch, so a hostile input cannot flip one. The local compose app is reachable only via an explicit allow_loopback opt-in at the trusted call site.

The web-server enforces a parallel rule at boot (apps/web-server/server/config.py): it refuses to start when DISABLE_AUTH=true while HOST is not loopback (an unauthenticated control plane on the network), unless an explicit escape-hatch env var is set. A live pytest run is exempted via PYTEST_CURRENT_TEST (set only while pytest runs, never in a deployment) so the trusted-sandbox CI bind on 0.0.0.0 still works while production stays protected. The issue-dispatch workflow (.github/workflows/tfactory-dispatch.yml) likewise routes the untrusted issue title through an env: var rather than interpolating it into a curl command line (Actions script-injection fix).

What’s NOT in the architecture yet

Development environment (Nix / devenv)

TFactory ships a flake.nix declaring a devShells.default that gives you a reproducible NixOS-friendly dev shell. Entry is a single command:

nix develop

Or with direnv auto-loading:

direnv allow

What’s in the shell:

Env defaults (overridable per-shell):

nix flake check validates the shell builds across x86_64-linux. nix fmt formats .nix files via nixpkgs-fmt. The legacy shell.nix remains for nix-shell users; new development should prefer the flake path.

Cross-references