Skip to content
All posts
18 min readworkflows, internals

Retiring the RAG pipeline

TerraTitan converted AWS CDK constructs with a vector database, two blocking human gates and a one-shot generator. Its replacement is four JavaScript files. This article breaks down how we got there.

by Vincent De Smet

TerraTitan is the machine that ports AWS CDK L2 constructs into TerraConstructs. The first version was a Mastra workflow: an Upstash vector index over the Terraform AWS provider, an embedding-and-rerank retrieval step, two points where the run suspended and waited for a human, and Gemini generating the result in a single few-shot prompt.

One year later—an eternity in LLM advancement—the replacement has a core operational layer of four workflow scripts, backed by a few small deterministic tools and Markdown rulebooks. There is no vector database, no embedding step, no suspend/resume, and no API keys beyond basic Claude Code harness authentication. Across seven conversion runs, it has ported AWS CDK modules into the library and landed them as merged pull requests.

This article breaks down how we got there: what changed, what the runs measured, and what the workflow still cannot prove.

7
conversion runs
28.1M
tokens across 14 journals
306
agents spawned

What the old pipeline did

The old workflow layered setup and static discovery, two blocking human reviews, retrieval-driven mapping, and source/test generation. The human reviews made sense as safety checks, but they also held the whole run open.

The Mastra conversion workflow: workspace setup, static scan, a human file-selection gate, RAG retrieval against an Upstash vector index, a second human mapping-review gate, context export, Gemini generation of source and tests, and a write step with no verification after it.ensure-upstreamaws-cdk @ pinned tagensure-workspaceclone terraconstructs/basefind-input-refsstatic scan → CfnXxxfilter-input-filessuspend · human deselects fileshuman in the loop #1find-lib-output-refsone vector query per L1 classRAGUpstash index · 1,526 resourcestext-embedding-3-small · topK 10gpt-4o-mini rerank · topK 5review-cdktf-refssuspend when score ≤ 0.7 · pick 2–5human in the loop #2export-conversion-contextprompts + mappings to diskconvert-sourcegemini-2.5-pro · 2-shotconvert-testgemini-2.5-pro · 2-shotwrite-to-workspaceno compile gate · no test gate · no fix loop
Two suspend points, a vector index in the middle, and nothing downstream of generation.

When the reranker scored a candidate at 0.7 or below, a human had to choose among the five returned mappings.

Interestingly, the retrieved chunks were never what the generator saw. Once a human confirmed a mapping, the selected type name was used to look up data/reference/merged/provider-aws/<resource>/index.d.ts, and a compressed file containing only type definitions went into the prompt to ground the TypeScript generator. The vector database only bridged the CloudFormation-to-Terraform-provider-AWS mapping semantically.

But once the initial conversion ran, no gate of any kind looped over the outputs. There was no compile check, test run, or fix loop.

This planned expansion stalled when HashiCorp announced the deprecation of Terraform for CDK. That created an opportunity to establish the official, community-maintained CDKTN.io fork. After six months of stabilization and revival efforts under the Open Constructs Foundation, it was time to revisit TerraTitan.

The inversion

The new system's design note captures the goal of the rewrite:

Two things replaced retrieval:

Live types instead of an index. Every agent is pointed at node_modules/@cdktn/provider-aws/lib/<resource>/index.d.ts inside the worktree it is compiling, plus an exact-tag aws-cdk-lib install for the Cfn* definitions. The mapping prompt literally instructs ls $PROVIDER | grep -i $prefix. With Claude Code, agents inspect the files in the active worktree directly. There are no snapshots to refresh, because the ground truth is the same files the compiler will use. The entire staleness class disappears by construction.

An AST scan instead of LLM discovery. Early runs spent LLM tokens working out which files to convert and in what order. tools/cfn-scan.mjs now reads the TypeScript source and writes down those facts deterministically.

Think of it as a dependency-aware checklist. If service.ts imports task-definition.ts, the task definition must exist before the service can import it. The scanner puts task-definition.ts in an earlier conversion wave, then puts service.ts in a later one. Files in the same wave can be converted in parallel because they do not depend on one another.

cfn-scan reads TypeScript files, finds which files import other files, and groups them into conversion waves. A task definition comes before a service that imports it; unrelated files in the same wave can be converted at the same time.upstream TypeScript filestask-definition.tsuses CfnTaskDefinitionlogging.tsuses helper codeimport relationship found by the scannerservice.tsimports task-definition.tsconversion wavesWave 1task definition + loggingWave 2after its import existsclassification guides the workL1_BACKED → map and convertPURE_L2 → copy with small import/header editsBARREL → wire exports after destination paths exist
An illustrative dependency chain: convert the task definition first, then the service that imports it. The scanner also tells the workflow how much judgment each file needs.

The scanner also classifies each file so the workflow can choose the right treatment:

  • L1_BACKED files use one or more Cfn* resources. They need a real conversion, including a CloudFormation-to-Terraform mapping.
  • PURE_L2 files only compose other L2 constructs or helpers. The workflow can copy them nearly verbatim, adjust imports and headers, and send many of them to one lower-effort agent.
  • BARREL files mostly re-export other files. They are wired after the destination paths are known.

The scanner emits the Cfn inventory, conversion waves, cross-module prerequisites, and those classifications in about a second. It replaced LLM-based inventory discovery with a parser, leaving the agents to spend their effort on mapping and code changes that require judgment.

What a dynamic workflow looks like

The workflow is a plain JS script. It spawns agents, and it branches on what they return.

phase('Plan')
const plan = await agent(planPrompt, { model: 'opus', schema: PLAN_SCHEMA })

phase('Convert')
for (const ord of orders) {
  const wave = plan.srcFiles.filter((f) => f.order === ord)
  await parallel(batchesOf(wave).map((batch) => () =>
    agent(convertPrompt(batch), { model: 'sonnet', schema: CONVERT_SCHEMA })))
}

Three properties matter more than the syntax.

Models are pinned per agent and never inherited. Sonnet converts and fixes; opus plans, adversarially checks the mappings, verifies independently, and reviews. Mechanical agents — the copy-mode batch, codegen, git add -A — run at low reasoning effort. For first-time Dynamic Workflows adopters, one rule is crucial: never inherit the session model.

Nearly every agent returns structured data that the script can read. plan.srcFiles decides the fan-out shape. plan.integChoice decides which integration test gets ported. verify.violations.length decides whether a fixer agent gets spawned at all. The workflow is dynamic because the graph is computed from what the previous phase found rather than drawn in advance.

Loops are bounded and the verifier is independent. Compile and test retries are capped at six attempts; integration synthesis is capped at three; verification is limited to three rounds with a fixer in between.

The Claude Code dynamic workflow: live type definitions and an AST scanner feed an opus planner, a two-stage mapping pipeline, batched sonnet converters, bounded compile, test and integration loops, an independent opus verifier, and a review step that ends in a pull request.ground truth, read liveworktree node_modules/@cdktn/provider-awstools/cfn-scan.mjs — inventory + wavesPlanwaves · copyMode · locopusMap — findcandidate resourcessonnetMap — verifyadversarialopusConvert — wavesbatched to ≤700 upstream LOCsonnetCompiletsc → fixsonnet≤6Testjest → fixsonnet≤6Integsynth · go vet · tofu validatesonnet≤3Verify — independentviolations block · advisories don'topus≤3Reviewreport file firstopusPull requestcommitted plan · mappings · reporthuman review is asynchronous, on artifacts —never a blocking prompt mid-run
Deterministic inputs at the top, pinned models per phase, bounded retry loops on the right, and artifacts prepared for pull-request review rather than a blocking prompt.

Blocking, per-file, mid-run CLI prompts became asynchronous review of committed artifacts—the plan JSON, mapping manifests, reports, and pull-request diffs. The reviewing agent records the sign-offs a human should consider before a PR. The external review of the ECS pull request came back with seven blocking findings, so the review load is real; it simply no longer holds the pipeline open.

Seven runs

Run 1 — aws-sqs

Green, and self-certified

Compiled and passed its own tests in 52 minutes. The tests were written by the same pipeline. It had quietly dropped the repo's physical-naming invariant.

Run 2 — aws-autoscaling

The verification ladder

Added an independent verify phase and a live apply. The live apply found a bug all three static gates missed.

Run 3 — aws-secretsmanager

Blind adjudication

Run against an existing human PR the agents were forbidden to read. Won on mapping, lost on API design.

Run 4 — aws-servicediscovery

Tooling A/B — goal missed

The deterministic scanner reduced per-agent cost by 33%, but the token target still missed because file-level fan-out created too much fixed overhead.

Run 5 — aws-servicediscovery

Granularity, controlled

Same module, same inputs, only the script changed. −21% tokens, −25% wall clock, strictly better output.

Runs 6 & 7 — aws-ecs, aws-batch

Scale, then live deploys

37k lines at the best ratio yet, then a live-deploy campaign that found seven defects no static gate could see.

The numbers below come from the harness's own run journals.

RunModuleUpstream LOCTokensAgentstok/LOCtok/agent
2aws-autoscaling8,8294.39M4549797.6k
3aws-secretsmanager4,9292.50M24507104.2k
4aws-servicediscovery2,5472.46M3596670.3k
5aws-servicediscovery2,5471.94M3076264.8k
6aws-ecs37,1618.52M71229119.9k
7aws-batch9,9474.22M4342598.2k

Runs 1 through 5 all happened inside 21 hours, orchestrated by Fable. Some attempts do not appear in the table because they were misfires corrected two minutes later.

Run 1: never let the generator be its own oracle

The first run reached compile-green and test-green on its own, which felt like a result for about as long as it took the reviewing agent to diff the output against the reference implementation.

The verdict identified a crucial gap in its initial design:

the "passing" state is self-certified: the generator authored the tests from upstream CloudFormation semantics and then made its own output satisfy them, so the checks were blind to the repo's actual invariants.

Concretely: the repo's stack-scoped physical naming was gone, replaced with a plain name. A public handle had been made private. Policy statements grew an attribute they shouldn't have. All five golden snapshot tests had vanished. And one fix-loop agent had "repaired" a failing assertion by removing the resolve call from one side of it, turning it into a tautology.

Fable determined the fix wasn't better tests. It was a structurally independent verifier that reads the files, the snapshots and the git diff against an explicit rule list, plus the rulebook those rules live in. Invariant six in that document reads: tests are not the oracle.

Run 2: each oracle catches what the one below cannot

Run 2 added the independent verify phase, and then went further and deployed the result.

aws_autoscaling_schedule defaults unset min/max/desired values to zero, and reserves −1 as the "don't modify this" sentinel. An undefined passing straight through produced a request AWS rejected at apply time. Jest was green. The convention verifier was green. tofu validate was green. Only a real apply against a real account failed.

That produced the ladder every run since has climbed: jest for shape, independent verification for invariants, tofu validate for provider schema, live apply for provider runtime semantics—and, by run 7, a drift oracle that asserts a second plan comes back empty. In these runs, each added check found failures the earlier checks had not detected.

Run 3: verify the completed artifact

Run 3 was an adjudication: the system converted aws-secretsmanager blind, then diffed the result against a pull request a human had already written, which the agents were forbidden to read.

It won the mapping. Faced with a CloudFormation resource that has no Terraform equivalent, it produced a composition rather than hallucinating a resource that doesn't exist, and independently derived a validation error the human PR only got after review. It lost the API design: its version model made the canonical upstream call pattern throw.

The structural lesson was smaller and more useful. Verification ran before the integration leg, so it reported violations about artifacts that had not been produced yet—and each phantom finding triggered a fix round. Moving integration ahead of verification removed that class of false finding.

Run 4: the miss that located the next optimization

Run 4 swapped LLM inventory work for the AST scanner and measured it.

Run 4 matched the scanner's inventory exactly, included no deprecated members (the first run to pass a gate that Runs 2 and 3 both failed), required no manual fixes afterward, and received a review verdict of no correctness defects found.

The run used 966 tokens per LOC against a 350-token-per-LOC target—about 2.8 times the target.

RunModuleUpstream LOCTokensAgentstok/LOCtok/agent
2 (no tools)autoscaling8,8294.39M4549797.6k
3 (no tools)secretsmanager4,9292.50M24507104.2k
4 (tools)servicediscovery2,5472.46M35966 ✗70.3k ✓

Both axes moved, in opposite directions. Per-agent cost fell by a third — the tooling worked exactly as intended. Agent count rose 46% on a module half the size, because fan-out was one agent per file and servicediscovery is twelve small files averaging 110 lines where secretsmanager was five averaging 265.

Fixed per-agent overhead (reading conventions + plan + mapping + siblings ≈ 40–70k tokens) dominates for many-small-file modules. LOC-normalization hides granularity effects; per-agent cost is the truer tooling signal.

Both metrics are needed. tok/LOC measures the workload; tok/agent measures the overhead. Report only one and you will misread the experiment.

Run 5: the granularity A/B

Same module, same tag, same inputs, an explicit integrity guard forbidding the agents from reading run 4's output — and only the script changed.

Converter fan-out compared. Run 4 spawned one agent per file: twelve agents across six waves. Run 5 batched files to a 700-line budget: five agents across five waves, with wave four converting seven files in a single agent.Run 4 — one agent per file12 converter agents · 6 wavesRun 5 — one agent per 700 LOC5 converter agents · 5 wavesw1w2w3w4w5w6w1w2w3w4w57 files · 697 of 700 LOCsame module · same tag · same inputs — only the script changed−21% tokens · −25% wall clock
Each pill is one agent; each square inside it is a file that agent converted. Wave four is the point: seven files, 697 of a 700-line budget, one agent. Tap to zoom.

Batching became possible once the planner emitted facts the script could act on. In Run 4 the planner already knew which files were pure-L2 boilerplate; it wrote so in a free-text notes field, where no if statement could reach it. Run 5 promoted loc and copyMode to required schema fields.

Then the converter loop could budget:

Run 4 — per fileRun 5 — per 700 LOC
for (const ord of orders) {
  const wave = plan.srcFiles
    .filter(f => f.order === ord)

  await parallel(wave.map(f => () =>
    agent(convertOne(f), {
      model: 'sonnet',
      schema: CONVERT_SCHEMA,
    })))
}
const BATCH_LOC = 700
for (const ord of orders) {
  const wave = plan.srcFiles
    .filter(f => f.order === ord)
  const copies = wave.filter(f => f.copyMode)
  const real = wave.filter(f => !f.copyMode)
  const batches = budget(real, BATCH_LOC)
  if (copies.length) batches.push(copies)

  await parallel(batches.map(b => () =>
    agent(convertMany(b), {
      model: 'sonnet',
      schema: CONVERT_SCHEMA,
      ...(b[0].copyMode ? { effort: 'low' } : {}),
    })))
}

Copy-mode files — the ones with no CloudFormation resource behind them — all go to a single agent, with a different prompt and reduced reasoning effort. The rulebook got split too: converters now read a 49-line core sheet instead of the 26 KB full document, which every converter had previously been paying for.

The last change fixed a latent script bug. Run 4's verify schema had only violations[], and its journal records the consequence — verifyPass: true, unresolvedViolations: 3. The opus verifier had returned "pass" while listing three violations, because it had nowhere to put a judgment call. Run 5 added an advisories[] array, tightened the gate to pass && violations.length === 0, and told the verifier which findings belong where. Advisories never trigger a fix round.

Run 4Run 5Δ
Tokens2.46M1.94M−21%
Agents3530−14%
Tokens/agent70.3k64.8k−8%
Duration98 min74 min−25%
Correctness defects reported00
Verify0 blocking + 3 conflated0 blocking + 5 cleanly-split advisories

Two honest footnotes on that table. The converter phase saved seven agents, not five—verification needed a second round in Run 5 whereas Run 4 passed on the first attempt, because the tightened gate caught two real violations the old gate would have waved through. And the planner also chose a different wave grouping and a different integration test, so a run advertised as controlled had one uncontrolled variable inside it.

Run 4's reviewer reported no correctness defects, but Run 5's A/B diff found one it had missed: a property injection identifier omitted its submodule segment, and the defect had already shipped in a merged pull request. A clean bill of health from the same family of models that wrote the code is weak evidence; this project's own artifacts provide a concrete counterexample.

Runs 6 and 7: what scale and reality added

aws-ecs was 45 files and 37,000 lines, and achieved the best ratio of any run—229 tokens per line— because the fixed overhead finally had something to amortize against. It also found a new failure mode: after two schema-validation failures the review agent returned placeholder junk that passed validation. Every workflow since writes its report to a file first and returns only a pointer.

Then the integration tests were actually deployed, and four construct defects surfaced that jest, snapshots and tofu validate had all missed: scaling policies referencing raw strings instead of resource attributes, so no dependency edge existed and two AWS calls raced; a name-length validator that rejected names generated by the code itself; a volume policy destroyed in parallel with the service that needed it, wedging the task; and a desired_count sentinel that created a service with zero tasks. Run 7 added a drift oracle and immediately caught a block that Terraform would have re-planned forever.

That campaign became port-integ-tests.js, a reusable workflow with a playbook based on failures observed during real deployments.

What generalizes

  1. Never let the generator be its own oracle. Compile-green and test-green mean nothing when the same pipeline wrote the tests. You need a verifier that is structurally independent, not merely a second prompt.
  2. Structured output is for the script, not the reader. If your control flow needs a fact, put the fact in the schema. Run 5's entire win traces back to promoting two fields out of a prose notes string.
  3. Batch to a work budget, not a file count. Fixed per-agent overhead makes one-file-per-agent a tax.
  4. Tier by whether the work needs judgment. Model and reasoning effort are per-call. Pin both; inheriting the session model makes cost unpredictable across runs.
  5. Give the verifier somewhere to put opinions. With only a blocking channel, an opinionated reviewer either fails the run over a style preference or passes while listing violations.
  6. Use tools for deterministic work and prompts for judgment. A 141-line AST script replaced a vector database, an embedding budget, and a manual re-index ritual.
  7. Order phases so the verifier sees the finished artifact. Running verification too early creates phantom findings and unnecessary fix rounds.
  8. Add checks in order of cost and coverage. Keep the checks that find failures the cheaper ones miss. In these runs, later checks exposed failures earlier checks did not.
  9. A/B the pipeline, not just the output. Use the same inputs, change one variable, and prevent agents from reading the previous result. That turns “this feels better” into a measurement.

What this does not prove

Runs 4 and 5 are the only controlled experiment; every other run-to-run delta changes the module and the pipeline at the same time. Module size dominates the headline ratio — fitting tokens against lines across the full-pipeline runs suggests a fixed floor around 2M tokens per run plus roughly 180 per line, which means the 350 tokens-per-line goal was unreachable for small modules no matter what the script did. The floor alone exceeds the entire budget for a 2,500-line module. That regression mixes five pipeline versions, so treat it as a shape, not a constant.

The scope is not like-for-like either: the new pipeline ports source, tests and integration tests, and deploys them. Some of the extra spend produces more artifacts. Wall-clock is not clean—part of Run 5's 25% came from Run 4 hitting two stall-and-retry events. The A/B's quality advantage comes from concrete, checkable findings, but its aggregate verdict is still model judgment.

The honest summary is narrower than the numbers make it look. A vector database was replaced by a grep and an AST scan. Two blocking human gates became reviewable artifacts and PR-level review. Re-conversions showed controlled quality improvements as the new workflow evolved. The old pipeline's generator call was cheap, but the new workflow now reaches tests and live integration validation in less than half a day instead of leaving the remaining iteration to a week of human effort, depending on module size.

Questions, or want to help?

TerraConstructs is Apache-2.0 and built in the open. The fastest way to get help is the CDK community Slack.